diff --git a/app/modules/accounting/ui.py b/app/modules/accounting/ui.py index 89fab17..4a3dc90 100644 --- a/app/modules/accounting/ui.py +++ b/app/modules/accounting/ui.py @@ -144,15 +144,20 @@ def _enrich_gstin_display(live_result: dict | None, registrations) -> None: erp_gstins = [] live_company = company_by_guid.get(str(mapping.get("tally_guid") or "").strip()) or {} - tally_gstin = _normalise_gstin(live_company.get("gstin") or mapping.get("gstin")) + tally_gstins: list[str] = [] + for value in list(live_company.get("gstins") or []) + [live_company.get("gstin"), mapping.get("gstin")]: + gstin = _normalise_gstin(value) + if gstin and gstin not in tally_gstins: + tally_gstins.append(gstin) mapping["erp_gstins"] = erp_gstins - mapping["tally_gstin_display"] = tally_gstin + mapping["tally_gstins"] = tally_gstins + mapping["tally_gstin_display"] = ", ".join(tally_gstins) - if erp_gstins and tally_gstin: - mapping["gstin_match_status"] = "matched" if tally_gstin in erp_gstins else "mismatch" - elif erp_gstins and not tally_gstin: + if erp_gstins and tally_gstins: + mapping["gstin_match_status"] = "matched" if set(erp_gstins).intersection(tally_gstins) else "mismatch" + elif erp_gstins and not tally_gstins: mapping["gstin_match_status"] = "tally_missing" - elif tally_gstin and not erp_gstins: + elif tally_gstins and not erp_gstins: mapping["gstin_match_status"] = "erp_missing" else: mapping["gstin_match_status"] = "unavailable" diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 10918e4..b551b48 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.9.4" +ERP_LOCAL_AGENT_VERSION = "1.9.5" 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/README_ERP_LOCAL_AGENT.txt b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt index d95819a..b19b8a4 100644 --- a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt +++ b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt @@ -1,4 +1,4 @@ -ERP Local Agent 1.9.4 +ERP Local Agent 1.9.5 Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved. @@ -90,3 +90,10 @@ Tally GSTIN enrichment 1.9.4: - Loaded-company discovery requests GSTRegistrationNumber, GSTRegistrationDetails, GSTIN and PartyGSTIN. - Company parser searches GSTIN candidates recursively, including nested GST registration details. - Existing company GUID/name discovery remains unchanged. + +Tally GST Registration master enrichment 1.9.5: +- Keeps existing loaded-company Name/GUID discovery unchanged. +- Reads GST registrations from TallyPrime Tax Unit masters using a filtered COMPUTE collection, avoiding the Tax Unit enumeration timeout seen with direct NATIVEMETHOD/FETCH probes. +- Supports multiple GST registrations for one Tally company while preserving the existing primary gstin field for backward compatibility. +- GST enrichment failure is non-fatal: company discovery, GUID mapping and all existing accounting functions remain available. +- Mapping validation accepts the ERP GSTIN when it matches any GST registration reported by the selected Tally company. 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 cf0d4e4..094c149 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.9.4" +__version__ = "1.9.5" AGENT_NAME = "ERP Local Agent" 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 a14c167..1a88cd5 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 @@ -111,14 +111,22 @@ class AgentCommandProcessor: if not company: raise ValueError("The selected Tally company is no longer loaded. Refresh Tally companies and try again.") company_name = str(company.get("name") or "").strip() - company_gstin = str(company.get("gstin") or "").strip().upper() + company_gstins: list[str] = [] + for value in list(company.get("gstins") or []) + [company.get("gstin")]: + gstin = str(value or "").strip().upper() + if gstin and gstin not in company_gstins: + company_gstins.append(gstin) + company_gstin = company_gstins[0] if company_gstins else "" if not company_name: raise ValueError("Tally returned an invalid company name.") if registration: reg_type = str(registration.get("registration_type_code") or "").strip().upper() reg_number = str(registration.get("registration_number") or "").strip().upper() - if reg_type == "GSTIN" and reg_number and company_gstin and reg_number != company_gstin and not allow_gstin_mismatch: - raise ValueError(f"GSTIN mismatch: ERP registration is {reg_number}, but Tally company reports {company_gstin}. Verify the company or explicitly allow the mismatch.") + if reg_type == "GSTIN" and reg_number and company_gstins and reg_number not in company_gstins and not allow_gstin_mismatch: + reported = ", ".join(company_gstins) + raise ValueError(f"GSTIN mismatch: ERP registration is {reg_number}, but Tally company reports {reported}. Verify the company or explicitly allow the mismatch.") + if reg_type == "GSTIN" and reg_number in company_gstins: + company_gstin = reg_number self.store.record_tally_status(client_id, tally_status) mapping = self.store.map_company(client_id, tally_guid=requested_guid, company_name=company_name, gstin=company_gstin, registration=registration, mapped_by_user_id=int(mapped_by_user_id) if mapped_by_user_id not in (None, "") else None) return {"mapped": True, "mapping": mapping, "accounting": self.store.snapshot(client_id), "tally": tally_status, "agent": self._agent_info()} diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py index 709e4e3..9c7c791 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py @@ -91,8 +91,9 @@ class TallyCompany: name: str guid: str = "" gstin: str = "" + gstins: tuple[str, ...] = () - def as_dict(self) -> dict[str, str]: + def as_dict(self) -> dict: return asdict(self) @@ -205,7 +206,83 @@ class TallyLiveConnector: """ - return self._parse_companies(self._post_xml(xml)) + companies = self._parse_companies(self._post_xml(xml)) + enriched: list[TallyCompany] = [] + for company in companies: + gstins = self._get_company_gstins(company.name) + if not gstins and company.gstin: + gstins = (company.gstin.strip().upper(),) + primary_gstin = gstins[0] if gstins else company.gstin + enriched.append( + TallyCompany( + name=company.name, + guid=company.guid, + gstin=primary_gstin, + gstins=gstins, + ) + ) + return enriched + + def _get_company_gstins(self, company_name: str) -> tuple[str, ...]: + """Return GST registrations configured in the selected Tally company. + + TallyPrime 3+ stores GST registrations as Tax Unit masters. A direct + NATIVEMETHOD/FETCH enumeration can stall on some Tally builds, while the + filtered COMPUTE collection below is stable and has been verified against + the GST Registration master shown in TallyPrime. Failure to enrich GSTINs + must never make otherwise-working company discovery unavailable. + """ + company = str(company_name or "").strip() + if not company: + return () + xml = f""" +
+ 1ExportCollectionARRRAccountingGSTRegistrations +
+ + {self._static_variables(company)} + + + Tax Unit + MasterName : $Name + GSTNumber : $GSTRegNumber + GSTState : $StateName + GSTUsedFor : $UsedFor + ARRRAccountingHasGSTNumber + + NOT $$IsEmpty:$GSTRegNumber + + +
""" + try: + return self._parse_gst_registration_numbers(self._post_xml(xml)) + except TallyConnectionError: + return () + + @staticmethod + def _parse_gst_registration_numbers(xml_text: str) -> tuple[str, ...]: + cleaned = _clean_xml_response(xml_text) + if not cleaned: + return () + values: list[str] = [] + try: + root = ET.fromstring(cleaned.encode("utf-8")) + for element in root.iter(): + if _tag(element) != "TAXUNIT": + continue + candidates = [ + _child_text(element, "GSTREGNUMBER"), + str(element.attrib.get("TAXREGISTRATION") or ""), + ] + for candidate in candidates: + value = re.sub(r"\s+", "", candidate.strip().upper()) + if re.fullmatch(r"[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]Z[0-9A-Z]", value) and value not in values: + values.append(value) + except Exception: + for match in re.findall(r"\b[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]Z[0-9A-Z]\b", cleaned.upper()): + if match not in values: + values.append(match) + return tuple(values) def status(self) -> dict: try: @@ -573,5 +650,13 @@ class TallyLiveConnector: "GSTNUMBER", ), ) - companies.append(TallyCompany(name=name, guid=_child_text(element, "GUID"), gstin=gstin)) + normalised_gstin = re.sub(r"\s+", "", gstin.strip().upper()) + companies.append( + TallyCompany( + name=name, + guid=_child_text(element, "GUID"), + gstin=normalised_gstin, + gstins=(normalised_gstin,) if normalised_gstin else (), + ) + ) return companies