Simplify Tally mirror workflow and use GUID company identity

This commit is contained in:
A R R R Associates
2026-09-06 19:20:41 +05:30
parent 9500668413
commit b46a618b13
5 changed files with 73 additions and 33 deletions
@@ -10,7 +10,7 @@
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting</p> <p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Tally Accounting</h1> <h1 class="mt-1 text-2xl font-semibold text-slate-900">Tally Accounting</h1>
<p class="mt-1 text-sm text-slate-500">Company mapping, master sync and transaction sync remain available. Accounting tools are now grouped into Purchases & Expenses and Sales & Income without changing existing routes or controls.</p> <p class="mt-1 text-sm text-slate-500">Mirror Tally once into the client SQLite Accounting Mirror, then run accounting and audit analysis from the local .act database. Existing mapping and legacy sync controls remain available under Advanced controls.</p>
</div> </div>
<div class="w-full space-y-3 xl:max-w-5xl"> <div class="w-full space-y-3 xl:max-w-5xl">
<div class="rounded-2xl border border-blue-200 bg-blue-50/40 p-4"> <div class="rounded-2xl border border-blue-200 bg-blue-50/40 p-4">
@@ -222,8 +222,8 @@
<section class="rounded-2xl bg-white p-5 shadow-soft" id="full-accounting-export"> <section class="rounded-2xl bg-white p-5 shadow-soft" id="full-accounting-export">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h2 class="font-semibold text-slate-900">Full Accounting Export · ODBC → SQLite</h2> <h2 class="font-semibold text-slate-900">Mirror Tally to SQLite</h2>
<p class="mt-1 text-sm text-slate-500">Export the selected mapped Tally company into the client Accounting Mirror .act database in local storage. This refreshes ledgers, vouchers, voucher ledger entries, stock items, stock movements, HSN and GST rates, then refreshes the existing client .act snapshots used by older accounting tools.</p> <p class="mt-1 text-sm text-slate-500">Create or refresh the client Accounting Mirror from Tally using the high-speed read-only ODBC path. After this finishes, ERP analysis tools read the local SQLite .act data instead of repeatedly extracting from Tally.</p>
</div> </div>
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Read-only export</span> <span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">Read-only export</span>
</div> </div>
@@ -243,7 +243,7 @@
</label> </label>
<label class="text-sm font-medium text-slate-700">From Date<input type="date" name="date_from" required value="{{ transaction_date_from }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" /></label> <label class="text-sm font-medium text-slate-700">From Date<input type="date" name="date_from" required value="{{ transaction_date_from }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" /></label>
<label class="text-sm font-medium text-slate-700">To Date<input type="date" name="date_to" required value="{{ transaction_date_to }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" /></label> <label class="text-sm font-medium text-slate-700">To Date<input type="date" name="date_to" required value="{{ transaction_date_to }}" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm" /></label>
<button id="fullExportButton" type="submit" class="rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">Export All to SQLite</button> <button id="fullExportButton" type="submit" class="rounded-xl bg-emerald-700 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-800">Mirror Tally to SQLite</button>
</form> </form>
<div id="fullExportProgress" class="mt-5 hidden rounded-xl border border-slate-200 bg-slate-50 p-4"> <div id="fullExportProgress" class="mt-5 hidden rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between gap-3"><div><div id="fullExportStage" class="font-semibold text-slate-900">Waiting…</div><div id="fullExportMessage" class="mt-1 text-xs text-slate-500"></div></div><div id="fullExportPct" class="text-lg font-bold text-slate-900">0%</div></div> <div class="flex items-center justify-between gap-3"><div><div id="fullExportStage" class="font-semibold text-slate-900">Waiting…</div><div id="fullExportMessage" class="mt-1 text-xs text-slate-500"></div></div><div id="fullExportPct" class="text-lg font-bold text-slate-900">0%</div></div>
@@ -254,8 +254,19 @@
</section> </section>
{% endif %} {% endif %}
<details class="rounded-2xl border border-slate-200 bg-white shadow-soft">
<summary class="cursor-pointer list-none px-5 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="font-semibold text-slate-900">Advanced / Legacy Tally Controls</h2>
<p class="mt-1 text-sm text-slate-500">Manual master sync, transaction sync, active mappings and loaded-company diagnostics are retained for compatibility and troubleshooting. Normal work only requires Mirror Tally to SQLite above.</p>
</div>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Optional</span>
</div>
</summary>
<div class="space-y-6 border-t border-slate-100 p-5">
{% if accounting and accounting.exists and tally and tally.connected %} {% if accounting and accounting.exists and tally and tally.connected %}
<section class="rounded-2xl bg-white p-5 shadow-soft"> <section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h2 class="font-semibold text-slate-900">Phase 3 · Accounting Master Sync</h2> <h2 class="font-semibold text-slate-900">Phase 3 · Accounting Master Sync</h2>
@@ -293,11 +304,11 @@
<div class="mt-4 rounded-xl bg-slate-50 px-4 py-3 text-xs text-slate-600"> <div class="mt-4 rounded-xl bg-slate-50 px-4 py-3 text-xs text-slate-600">
Synced into the selected client's .act database: Groups, Ledgers, Voucher Types, Stock Groups, Stock Categories, Stock Items, Units, Cost Categories and Cost Centres. No voucher or transaction data is imported in Phase 3. Synced into the selected client's .act database: Groups, Ledgers, Voucher Types, Stock Groups, Stock Categories, Stock Items, Units, Cost Categories and Cost Centres. No voucher or transaction data is imported in Phase 3.
</div> </div>
</section> </section>
{% endif %} {% endif %}
{% if accounting and accounting.exists and tally and tally.connected %} {% if accounting and accounting.exists and tally and tally.connected %}
<section class="rounded-2xl bg-white p-5 shadow-soft"> <section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h2 class="font-semibold text-slate-900">Phase 4 · Accounting Transaction Sync</h2> <h2 class="font-semibold text-slate-900">Phase 4 · Accounting Transaction Sync</h2>
@@ -341,11 +352,11 @@
<div class="mt-4 rounded-xl bg-slate-50 px-4 py-3 text-xs text-slate-600"> <div class="mt-4 rounded-xl bg-slate-50 px-4 py-3 text-xs text-slate-600">
Stored locally in the client's .act database: vouchers, ledger entries, inventory entries, bill allocations, cost-centre allocations and bank allocations. Raw voucher XML is preserved. Re-syncing a date range replaces only that company's vouchers in the selected range. No Tally data is modified. Stored locally in the client's .act database: vouchers, ledger entries, inventory entries, bill allocations, cost-centre allocations and bank allocations. Raw voucher XML is preserved. Re-syncing a date range replaces only that company's vouchers in the selected range. No Tally data is modified.
</div> </div>
</section> </section>
{% endif %} {% endif %}
{% if accounting and accounting.exists %} {% if accounting and accounting.exists %}
<section class="overflow-hidden rounded-2xl bg-white shadow-soft"> <section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-100 px-5 py-4"> <div class="border-b border-slate-100 px-5 py-4">
<h2 class="font-semibold text-slate-900">Active Tally Mappings</h2> <h2 class="font-semibold text-slate-900">Active Tally Mappings</h2>
<p class="text-sm text-slate-500">Mappings remain valid across Tally company renames because the GUID is the persistent key.</p> <p class="text-sm text-slate-500">Mappings remain valid across Tally company renames because the GUID is the persistent key.</p>
@@ -419,11 +430,11 @@
{% else %} {% else %}
<div class="p-6 text-sm text-slate-500">No Tally company has been mapped for this client yet.</div> <div class="p-6 text-sm text-slate-500">No Tally company has been mapped for this client yet.</div>
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}
{% if tally %} {% if tally %}
<section class="overflow-hidden rounded-2xl bg-white shadow-soft"> <section class="overflow-hidden rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-100 px-5 py-4"> <div class="border-b border-slate-100 px-5 py-4">
<h2 class="font-semibold text-slate-900">Loaded Tally Companies</h2> <h2 class="font-semibold text-slate-900">Loaded Tally Companies</h2>
<p class="text-sm text-slate-500">Read-only company discovery from the local TallyPrime instance.</p> <p class="text-sm text-slate-500">Read-only company discovery from the local TallyPrime instance.</p>
@@ -446,12 +457,15 @@
{% else %} {% else %}
<div class="p-6 text-sm text-slate-500">TallyPrime responded, but no loaded company was returned.</div> <div class="p-6 text-sm text-slate-500">TallyPrime responded, but no loaded company was returned.</div>
{% endif %} {% endif %}
</section> </section>
{% endif %} {% endif %}
</div>
</details>
{% endif %} {% endif %}
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800"> <div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">
Phase 4 synchronizes accounting transactions read-only. Tally write-back remains disabled and is reserved for Phase 6. Normal workflow: map the company once, mirror Tally to SQLite, then run analysis from the client .act database. Approved write-back tools remain controlled separately.
</div> </div>
</div> </div>
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.24.3" ERP_LOCAL_AGENT_VERSION = "1.24.4"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent" ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -1,2 +1,2 @@
__version__ = "1.24.3" __version__ = "1.24.4"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -354,10 +354,17 @@ class AccountingMirrorEngine:
actual_guid = _text(company.get("company_guid")) actual_guid = _text(company.get("company_guid"))
exp_name = _text(expected_company_name) exp_name = _text(expected_company_name)
exp_guid = _text(expected_company_guid) exp_guid = _text(expected_company_guid)
if exp_name and actual_name.casefold() != exp_name.casefold(): validation_warnings: list[str] = []
raise RuntimeError(f"Active Tally company mismatch. ERP expected '{exp_name}', ODBC returned '{actual_name}'.") # The Tally GUID is the permanent company identity. Company names are descriptive
# snapshots only and commonly contain FY/year suffixes (for example "-25-26").
# Never reject a valid mirror merely because the descriptive name changed.
if exp_guid and actual_guid and exp_guid != actual_guid: if exp_guid and actual_guid and exp_guid != actual_guid:
raise RuntimeError(f"Tally company GUID mismatch. ERP expected {exp_guid}, ODBC returned {actual_guid}.") raise RuntimeError(f"Tally company GUID mismatch. ERP expected {exp_guid}, ODBC returned {actual_guid}.")
if exp_name and actual_name and actual_name.casefold() != exp_name.casefold():
validation_warnings.append(
f"Tally company name differs from the ERP snapshot: ERP '{exp_name}', Tally '{actual_name}'. "
"The export was accepted because company identity is based on the Tally GUID."
)
report(96, "Publishing Accounting Mirror", "Publishing the verified SQLite mirror inside the client's Accounting folder.", rows=dict(rows)) report(96, "Publishing Accounting Mirror", "Publishing the verified SQLite mirror inside the client's Accounting folder.", rows=dict(rows))
paths.current_mirror.parent.mkdir(parents=True, exist_ok=True) paths.current_mirror.parent.mkdir(parents=True, exist_ok=True)
@@ -377,6 +384,7 @@ class AccountingMirrorEngine:
result = { result = {
"ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name, "ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name,
"company_guid": actual_guid, "company_cache_path": str(cache), "company_guid": actual_guid, "company_cache_path": str(cache),
"validation_warnings": validation_warnings,
"extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:], "extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:],
**self.status(client_id), **self.status(client_id),
} }
@@ -385,22 +393,37 @@ class AccountingMirrorEngine:
def company_db(self, client_id: int, company_name: str, company_guid: str = "") -> Path: def company_db(self, client_id: int, company_name: str, company_guid: str = "") -> Path:
paths = self.paths(client_id) paths = self.paths(client_id)
requested_name = _text(company_name)
requested_guid = _text(company_guid)
current = self._summary(paths.current_mirror) current = self._summary(paths.current_mirror)
c = current.get("company") or {} c = current.get("company") or {}
if current.get("ready") and _text(c.get("company_name")).casefold() == _text(company_name).casefold(): current_name = _text(c.get("company_name"))
if not company_guid or not _text(c.get("company_guid")) or _text(c.get("company_guid")) == _text(company_guid): current_guid = _text(c.get("company_guid"))
# GUID is authoritative and survives Tally company renames / FY suffix changes.
if current.get("ready"):
if requested_guid and current_guid and requested_guid == current_guid:
return paths.current_mirror return paths.current_mirror
exact = self._cache_file(paths, company_name, company_guid) if not requested_guid and current_name.casefold() == requested_name.casefold():
return paths.current_mirror
exact = self._cache_file(paths, requested_name, requested_guid)
if exact.is_file(): if exact.is_file():
return exact return exact
# GUID may be omitted by callers. Search only for an exact company-name prefix.
prefix = _safe_key(company_name) + "_" candidates = sorted(paths.cache_root.glob("*.act"), key=lambda p: p.stat().st_mtime, reverse=True)
candidates = sorted(paths.cache_root.glob(prefix + "*.act"), key=lambda p: p.stat().st_mtime, reverse=True) if requested_guid:
for candidate in candidates: for candidate in candidates:
s = self._summary(candidate) snapshot = self._summary(candidate)
c = s.get("company") or {} company = snapshot.get("company") or {}
if _text(c.get("company_name")).casefold() == _text(company_name).casefold(): if _text(company.get("company_guid")) == requested_guid:
return candidate return candidate
else:
for candidate in candidates:
snapshot = self._summary(candidate)
company = snapshot.get("company") or {}
if _text(company.get("company_name")).casefold() == requested_name.casefold():
return candidate
raise RuntimeError( raise RuntimeError(
f"No .NET/.act mirror snapshot is cached for Tally company '{company_name}'. " f"No .NET/.act mirror snapshot is cached for Tally company '{company_name}'. "
"Make that company active in TallyPrime and run Master/Transaction Sync once." "Make that company active in TallyPrime and run Master/Transaction Sync once."
@@ -2950,18 +2950,21 @@ class AgentCommandProcessor:
timeout_seconds=max(300, int(payload.get("timeout_seconds") or 1800)), timeout_seconds=max(300, int(payload.get("timeout_seconds") or 1800)),
progress_callback=mirror_progress, progress_callback=mirror_progress,
) )
publish(status="running", percent=98, stage="Updating client accounting database", message="Refreshing the existing client .act transaction snapshot from the completed ODBC mirror. Master analysis remains available directly from the mirror without clearing any existing auxiliary masters.", mirror_db_path=str(mirror_result.get("mirror_db_path") or "")) mirror_company_name = str(mirror_result.get("company_name") or company_name).strip() or company_name
transactions = self.tally.mirror.transactions(client_id, company_name, date_from, date_to, tally_guid) validation_warnings = list(mirror_result.get("validation_warnings") or [])
publish(status="running", percent=98, stage="Updating client accounting database", message="Refreshing the existing client .act transaction snapshot from the completed ODBC mirror. Master analysis remains available directly from the mirror without clearing any existing auxiliary masters.", mirror_db_path=str(mirror_result.get("mirror_db_path") or ""), warnings=validation_warnings)
transactions = self.tally.mirror.transactions(client_id, mirror_company_name, date_from, date_to, tally_guid)
transaction_sync = self.store.replace_transaction_snapshot( transaction_sync = self.store.replace_transaction_snapshot(
client_id, mapping={**mapping, "company_name": company_name}, transactions=transactions, client_id, mapping={**mapping, "company_name": mirror_company_name}, transactions=transactions,
requested_by_user_id=int(requested_by) if requested_by not in (None, "") else None, requested_by_user_id=int(requested_by) if requested_by not in (None, "") else None,
) )
mirror_summary = (mirror_result.get("mirror") or {}) mirror_summary = (mirror_result.get("mirror") or {})
counts = mirror_summary.get("counts") or {} counts = mirror_summary.get("counts") or {}
publish( publish(
status="completed", percent=100, stage="Complete", status="completed", percent=100, stage="Complete",
message="Full Accounting Export completed. ERP analysis tools can now read the refreshed SQLite data without re-extracting from Tally.", message=("Full Accounting Export completed. ERP analysis tools can now read the refreshed SQLite data without re-extracting from Tally."
finished_at_utc=datetime.now(timezone.utc).isoformat(), error="", + ((" " + validation_warnings[0]) if validation_warnings else "")),
finished_at_utc=datetime.now(timezone.utc).isoformat(), error="", warnings=validation_warnings,
rows=counts, mirror=mirror_summary, mirror_db_path=str(mirror_result.get("mirror_db_path") or ""), rows=counts, mirror=mirror_summary, mirror_db_path=str(mirror_result.get("mirror_db_path") or ""),
accounting_db_path=str(self.store.db_path(client_id)), accounting_db_path=str(self.store.db_path(client_id)),
transaction_sync=transaction_sync, transaction_sync=transaction_sync,