From b46a618b139529b179697e7260988a2dd1a7c2cd Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Sun, 6 Sep 2026 19:20:41 +0530 Subject: [PATCH] Simplify Tally mirror workflow and use GUID company identity --- .../templates/accounting/tally.html | 40 ++++++++++----- app/modules/documents/agent_package.py | 2 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/accounting_mirror.py | 49 ++++++++++++++----- .../erp_local_agent/commands.py | 13 +++-- 5 files changed, 73 insertions(+), 33 deletions(-) diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 354e827..5b6f35e 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -10,7 +10,7 @@

Tools · Accounting

Tally Accounting

-

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.

+

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.

@@ -222,8 +222,8 @@
-

Full Accounting Export · ODBC → SQLite

-

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.

+

Mirror Tally to SQLite

+

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.

Read-only export
@@ -243,7 +243,7 @@ - +
{% endif %} +
+ +
+
+

Advanced / Legacy Tally Controls

+

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.

+
+ Optional +
+
+
{% if accounting and accounting.exists and tally and tally.connected %} -
+

Phase 3 · Accounting Master Sync

@@ -293,11 +304,11 @@
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.
-
+
{% endif %} {% if accounting and accounting.exists and tally and tally.connected %} -
+

Phase 4 · Accounting Transaction Sync

@@ -341,11 +352,11 @@
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.
-
+
{% endif %} {% if accounting and accounting.exists %} -
+

Active Tally Mappings

Mappings remain valid across Tally company renames because the GUID is the persistent key.

@@ -419,11 +430,11 @@ {% else %}
No Tally company has been mapped for this client yet.
{% endif %} -
+
{% endif %} {% if tally %} -
+

Loaded Tally Companies

Read-only company discovery from the local TallyPrime instance.

@@ -446,12 +457,15 @@ {% else %}
TallyPrime responded, but no loaded company was returned.
{% endif %} -
+
{% endif %} + +
+
{% endif %}
- 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.
diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 3366617..47902d7 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.24.3" +ERP_LOCAL_AGENT_VERSION = "1.24.4" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index e6f3df5..4ad293b 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.24.3" +__version__ = "1.24.4" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py index 4488294..f64f24e 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_mirror.py @@ -354,10 +354,17 @@ class AccountingMirrorEngine: actual_guid = _text(company.get("company_guid")) exp_name = _text(expected_company_name) exp_guid = _text(expected_company_guid) - if exp_name and actual_name.casefold() != exp_name.casefold(): - raise RuntimeError(f"Active Tally company mismatch. ERP expected '{exp_name}', ODBC returned '{actual_name}'.") + validation_warnings: list[str] = [] + # 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: 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)) paths.current_mirror.parent.mkdir(parents=True, exist_ok=True) @@ -377,6 +384,7 @@ class AccountingMirrorEngine: result = { "ok": True, "synced_at_utc": _utc_now(), "company_name": actual_name, "company_guid": actual_guid, "company_cache_path": str(cache), + "validation_warnings": validation_warnings, "extractor_log_tail": log[-1500:], "loader_log_tail": llog[-1500:], **self.status(client_id), } @@ -385,22 +393,37 @@ class AccountingMirrorEngine: def company_db(self, client_id: int, company_name: str, company_guid: str = "") -> Path: paths = self.paths(client_id) + requested_name = _text(company_name) + requested_guid = _text(company_guid) current = self._summary(paths.current_mirror) c = current.get("company") or {} - if current.get("ready") and _text(c.get("company_name")).casefold() == _text(company_name).casefold(): - if not company_guid or not _text(c.get("company_guid")) or _text(c.get("company_guid")) == _text(company_guid): + current_name = _text(c.get("company_name")) + 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 - 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(): 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(prefix + "*.act"), key=lambda p: p.stat().st_mtime, reverse=True) - for candidate in candidates: - s = self._summary(candidate) - c = s.get("company") or {} - if _text(c.get("company_name")).casefold() == _text(company_name).casefold(): - return candidate + + candidates = sorted(paths.cache_root.glob("*.act"), key=lambda p: p.stat().st_mtime, reverse=True) + if requested_guid: + for candidate in candidates: + snapshot = self._summary(candidate) + company = snapshot.get("company") or {} + if _text(company.get("company_guid")) == requested_guid: + 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( 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." diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 1887b18..0cf06f3 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -2950,18 +2950,21 @@ class AgentCommandProcessor: timeout_seconds=max(300, int(payload.get("timeout_seconds") or 1800)), 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 "")) - transactions = self.tally.mirror.transactions(client_id, company_name, date_from, date_to, tally_guid) + mirror_company_name = str(mirror_result.get("company_name") or company_name).strip() or company_name + 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( - 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, ) mirror_summary = (mirror_result.get("mirror") or {}) counts = mirror_summary.get("counts") or {} publish( 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.", - finished_at_utc=datetime.now(timezone.utc).isoformat(), error="", + message=("Full Accounting Export completed. ERP analysis tools can now read the refreshed SQLite data without re-extracting from Tally." + + ((" " + 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 ""), accounting_db_path=str(self.store.db_path(client_id)), transaction_sync=transaction_sync,