From 93f2e02c3501e393e591649ee1afbf93d580c223 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Fri, 18 Sep 2026 19:06:41 +0530 Subject: [PATCH] Fix cross-FY Accounting Mirror creditor follow-up --- app/modules/documents/agent_package.py | 2 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/accounting_mirror.py | 65 +++++++++++++++++-- 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 4fdb1a3..6a7b6de 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.26.15" +ERP_LOCAL_AGENT_VERSION = "1.26.16" 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 404e11c..644ec6e 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.26.15" +__version__ = "1.26.16" 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 f4afae4..028c6c3 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 @@ -61,6 +61,29 @@ def _company_identity_name(value: Any) -> str: return re.sub(r"\s+", " ", text).strip() +def _company_fy_split_identity_name(value: Any) -> str: + """Normalize an FY-split Tally company name without weakening normal mapping checks. + + Tally users commonly keep a separate company for each financial year and Tally + may expose that company as ``NAME - (from 1-Apr-22)`` while the ERP mapping was + created against ``NAME Audit``. When ODBC returns only the ACTIVE_COMPANY token + there is no usable GUID to compare, so this helper removes only those explicit + FY-split/display qualifiers. It is used only when at least one of the compared + names carries a ``(from ...)`` marker; ordinary company-name mismatches remain + rejected. + """ + text = _text(value).strip().casefold() + text = re.sub(r"\s*[-–—]?\s*\(\s*from\s+[^)]*\)\s*$", "", text).strip() + text = re.sub(r"[\s_-]+(?:fy\s*)?\d{2,4}\s*[-/]\s*\d{2,4}\s*$", "", text).strip() + text = re.sub(r"\s+audit\s*$", "", text).strip() + text = re.sub(r"[^a-z0-9]+", " ", text) + return re.sub(r"\s+", " ", text).strip() + + +def _has_tally_from_period_marker(value: Any) -> bool: + return bool(re.search(r"\(\s*from\s+[^)]*\)\s*$", _text(value), flags=re.IGNORECASE)) + + def _safe_key(value: str) -> str: text = re.sub(r"[^A-Za-z0-9_.-]+", "_", _text(value)).strip("_") return text[:80] or "company" @@ -527,17 +550,40 @@ class AccountingMirrorEngine: if exp_guid and actual_guid_is_placeholder: expected_name_key = _company_identity_name(exp_name) actual_name_key = _company_identity_name(actual_name) - if not expected_name_key or not actual_name_key or expected_name_key != actual_name_key: + names_match = bool(expected_name_key and actual_name_key and expected_name_key == actual_name_key) + + # FY-split Tally companies can legitimately have a different display + # suffix, for example ERP ``RAHMAN RAHIM TRADERS Audit`` versus ODBC + # ``RAHMAN RAHIM TRADERS - (from 1-Apr-22)``. Accept that narrow case + # only when Tally explicitly exposes a '(from ...)' period marker and + # the underlying company-family name still matches. This keeps the + # existing safety rejection for unrelated company names. + fy_split_match = False + if not names_match and (_has_tally_from_period_marker(exp_name) or _has_tally_from_period_marker(actual_name)): + expected_family = _company_fy_split_identity_name(exp_name) + actual_family = _company_fy_split_identity_name(actual_name) + fy_split_match = bool(expected_family and actual_family and expected_family == actual_family) + + if not names_match and not fy_split_match: raise RuntimeError( "Tally ODBC did not return a usable company GUID " f"(returned '{actual_guid or 'blank'}') and the company name could not safely " f"confirm the saved mapping. ERP expected '{exp_name}', ODBC returned '{actual_name}'." ) - validation_warnings.append( - "Tally ODBC returned a selector token instead of the real company GUID " - f"('{actual_guid or 'blank'}'). The mirror was accepted because the normalized " - "company name matches the saved ERP mapping. The saved ERP GUID was preserved." - ) + + if fy_split_match: + validation_warnings.append( + "Tally ODBC returned a selector token instead of the real company GUID " + f"('{actual_guid or 'blank'}'). The mirror was accepted as an FY-split Tally company " + f"because ERP '{exp_name}' and ODBC '{actual_name}' resolve to the same base company name. " + "The saved ERP GUID was preserved." + ) + else: + validation_warnings.append( + "Tally ODBC returned a selector token instead of the real company GUID " + f"('{actual_guid or 'blank'}'). The mirror was accepted because the normalized " + "company name matches the saved ERP mapping. The saved ERP GUID was preserved." + ) if exp_name and actual_name and _company_identity_name(actual_name) != _company_identity_name(exp_name): validation_warnings.append( @@ -790,6 +836,13 @@ class AccountingMirrorEngine: current_name = _text(c.get("company_name")) current_guid = _text(c.get("company_guid")) + # The storage path is already bound by the ERP to one client + one FY. + # Analytical queries (including Sundry Creditors follow-up) intentionally + # do not need a live/current Tally company. If no company identity was + # supplied, the ready mirror in that bound FY folder is authoritative. + if current.get("ready") and not requested_guid and not requested_name: + return paths.current_mirror + # 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: