Add Phase 21A opening balance comparison and controlled Tally correction

This commit is contained in:
A R R R Associates
2026-08-25 17:45:42 +05:30
parent 0fab925e71
commit 6c9a97c1b3
11 changed files with 1418 additions and 4 deletions
@@ -1,2 +1,2 @@
__version__ = "1.21.0"
__version__ = "1.21.1"
AGENT_NAME = "ERP Local Agent"
@@ -40,6 +40,10 @@ class AgentCommandProcessor:
result = self._chart_of_accounts(payload)
elif action == "accounting_stock_master_intelligence":
result = self._stock_master_intelligence(payload)
elif action == "accounting_opening_balance_snapshot":
result = self._opening_balance_snapshot(payload)
elif action == "accounting_opening_balance_apply":
result = self._opening_balance_apply(payload)
elif action == "accounting_sync_transactions":
result = self._sync_transactions(payload)
elif action == "accounting_historical_evidence":
@@ -87,7 +91,7 @@ class AgentCommandProcessor:
return {
"name": "ERP Local Agent", "version": __version__,
"tally_capability": True, "accounting_act_capability": True,
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, "stock_master_intelligence_capability": True, "bank_reconciliation_read_capability": True,
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, "stock_master_intelligence_capability": True, "opening_balance_correction_capability": True, "bank_reconciliation_read_capability": True,
"tally_transaction_sync_capability": True,
"historical_learning_read_capability": True,
"purchase_posting_preflight_capability": True,
@@ -335,6 +339,67 @@ class AgentCommandProcessor:
def _opening_balance_company(self, company_name: str):
wanted=str(company_name or "").strip()
if not wanted: raise ValueError("Tally company name is required.")
matches=[row for row in self.tally.get_loaded_companies() if str(row.name or "").strip().casefold()==wanted.casefold()]
if len(matches)!=1: raise ValueError(f"Tally company '{wanted}' is not uniquely loaded. Open both previous/current companies in TallyPrime first.")
return matches[0]
def _opening_balance_snapshot(self, payload: dict[str, Any]) -> dict[str, Any]:
previous=self._opening_balance_company(str(payload.get("previous_company_name") or ""))
current=self._opening_balance_company(str(payload.get("current_company_name") or ""))
if previous.name.casefold()==current.name.casefold(): raise ValueError("Previous-year and current-year companies must be different.")
return {"previous_company":previous.as_dict(),"current_company":current.as_dict(),"previous_masters":self.tally.fetch_accounting_masters(previous.name),"current_masters":self.tally.fetch_accounting_masters(current.name),"read_only":True,"agent":self._agent_info()}
@staticmethod
def _opening_near(left: float, right: float, tolerance: float=0.01) -> bool:
return abs(float(left or 0)-float(right or 0))<=tolerance
def _opening_balance_apply(self, payload: dict[str, Any]) -> dict[str, Any]:
current=self._opening_balance_company(str(payload.get("current_company_name") or ""))
expected_guid=str(payload.get("current_company_guid") or "").strip()
if expected_guid and current.guid and expected_guid!=current.guid: raise ValueError("Current Tally company GUID changed since comparison. No opening balance was altered.")
masters=self.tally.fetch_accounting_masters(current.name)
ledgers={str(r.get("name") or "").strip().casefold():r for r in masters.get("ledgers") or []}
stocks={str(r.get("name") or "").strip().casefold():r for r in masters.get("stock_items") or []}
lr=[]; sr=[]; le=[]; se=[]
for req in list(payload.get("ledgers") or []):
name=str(req.get("name") or "").strip(); row=ledgers.get(name.casefold())
if not row: lr.append({"item_id":req.get("item_id"),"verified":False,"message":f"Current Ledger '{name}' no longer exists."}); continue
expected=float(req.get("expected_opening") or 0); actual=float(row.get("opening_balance") or 0)
if not self._opening_near(expected,actual): lr.append({"item_id":req.get("item_id"),"verified":False,"verified_opening":actual,"message":"Ledger opening changed after comparison; not altered."}); continue
le.append(req)
for req in list(payload.get("stock_items") or []):
name=str(req.get("name") or "").strip(); row=stocks.get(name.casefold())
if not row: sr.append({"item_id":req.get("item_id"),"verified":False,"message":f"Current Stock Item '{name}' no longer exists."}); continue
eq=float(req.get("expected_opening_qty") or 0); ev=float(req.get("expected_opening_value") or 0); aq=float(row.get("opening_balance") or 0); av=float(row.get("opening_value") or 0)
if not self._opening_near(eq,aq,0.000001) or not self._opening_near(ev,av): sr.append({"item_id":req.get("item_id"),"verified":False,"verified_opening_qty":aq,"verified_opening_value":av,"message":"Stock opening changed after comparison; not altered."}); continue
cu=str(row.get("base_units") or "").strip(); ru=str(req.get("unit") or "").strip()
if cu and ru and cu.casefold()!=ru.casefold(): sr.append({"item_id":req.get("item_id"),"verified":False,"message":f"Current Tally unit is '{cu}', not '{ru}'."}); continue
se.append(req)
for req in le:
try:
self.tally.alter_ledger_opening_balance(current.name,ledger_name=str(req.get("name") or "").strip(),opening_balance=float(req.get("target_opening") or 0))
lr.append({"item_id":req.get("item_id"),"verified":None,"target_opening":float(req.get("target_opening") or 0),"message":"Altered; verification pending."})
except Exception as exc: lr.append({"item_id":req.get("item_id"),"verified":False,"message":str(exc)})
for req in se:
try:
self.tally.alter_stock_item_opening(current.name,stock_item_name=str(req.get("name") or "").strip(),opening_qty=float(req.get("target_opening_qty") or 0),unit=str(req.get("unit") or "").strip(),opening_value=float(req.get("target_opening_value") or 0),opening_rate=float(req.get("target_opening_rate") or 0))
sr.append({"item_id":req.get("item_id"),"verified":None,"target_opening_qty":float(req.get("target_opening_qty") or 0),"target_opening_value":float(req.get("target_opening_value") or 0),"message":"Altered; verification pending."})
except Exception as exc: sr.append({"item_id":req.get("item_id"),"verified":False,"message":str(exc)})
verify=self.tally.fetch_accounting_masters(current.name)
vl={str(r.get("name") or "").strip().casefold():r for r in verify.get("ledgers") or []}; vs={str(r.get("name") or "").strip().casefold():r for r in verify.get("stock_items") or []}
lem={int(r.get("item_id") or 0):r for r in le}; sem={int(r.get("item_id") or 0):r for r in se}
for res in lr:
if res.get("verified") is not None: continue
req=lem.get(int(res.get("item_id") or 0)); row=vl.get(str(req.get("name") or "").strip().casefold()) if req else None; actual=float((row or {}).get("opening_balance") or 0); target=float((req or {}).get("target_opening") or 0); ok=self._opening_near(actual,target); res.update({"verified":ok,"verified_opening":actual,"message":"Ledger opening re-read and verified." if ok else f"Verification returned {actual:.2f}; expected {target:.2f}."})
for res in sr:
if res.get("verified") is not None: continue
req=sem.get(int(res.get("item_id") or 0)); row=vs.get(str(req.get("name") or "").strip().casefold()) if req else None; aq=float((row or {}).get("opening_balance") or 0); av=float((row or {}).get("opening_value") or 0); tq=float((req or {}).get("target_opening_qty") or 0); tv=float((req or {}).get("target_opening_value") or 0); ok=self._opening_near(aq,tq,0.000001) and self._opening_near(av,tv); res.update({"verified":ok,"verified_opening_qty":aq,"verified_opening_value":av,"message":"Stock opening re-read and verified." if ok else "Stock opening verification did not equal the approved quantity/value."})
return {"company_name":current.name,"company_guid":current.guid,"ledgers":lr,"stock_items":sr,"write_operation":True,"verified_after_write":True,"agent":self._agent_info()}
def _stock_master_intelligence(self, payload: dict[str, Any]) -> dict[str, Any]:
# Read-only against Tally. Existing local .act master storage remains
# synchronized by the normal master-sync path.
@@ -138,7 +138,7 @@ class TallyLiveConnector:
"collection": "ARRRAccountingStockItems",
"type": "StockItem",
"tag": "STOCKITEM",
"fetch": "Name,GUID,Parent,Category,BaseUnits,AdditionalUnits,OpeningBalance,OpeningValue,OpeningRate,GSTApplicable,GSTTypeOfSupply,HSNCode",
"fetch": "Name,GUID,Parent,Category,BaseUnits,AdditionalUnits,OpeningBalance,ClosingBalance,OpeningValue,ClosingValue,OpeningRate,ClosingRate,GSTApplicable,GSTTypeOfSupply,HSNCode",
},
"units": {
"collection": "ARRRAccountingUnits",
@@ -440,6 +440,42 @@ class TallyLiveConnector:
}
def _opening_master_import_result(self, response_xml: str, *, label: str) -> dict:
cleaned = _clean_xml_response(response_xml)
try:
root = ET.fromstring(cleaned.encode("utf-8"))
except Exception as exc:
raise ValueError(f"Tally returned invalid XML after {label}: {exc}") from exc
def _i(name: str) -> int:
try: return int(float(_first_text(root, [name]) or 0))
except Exception: return 0
created, altered, errors = _i("CREATED"), _i("ALTERED"), _i("ERRORS")
line_error = _first_text(root, ["LINEERROR"])
if errors > 0 or line_error: raise ValueError(line_error or f"Tally reported {errors} error(s) during {label}.")
if altered < 1 and created < 1: raise ValueError(f"Tally did not confirm the {label}.")
return {"created": created, "altered": altered, "errors": errors, "raw_response": response_xml}
def alter_ledger_opening_balance(self, company_name: str, *, ledger_name: str, opening_balance: float) -> dict:
company, name = str(company_name or "").strip(), str(ledger_name or "").strip()
if not company or not name: raise ValueError("Company and ledger name are required.")
amount=float(opening_balance or 0)
xml=f"""<ENVELOPE><HEADER><VERSION>1</VERSION><TALLYREQUEST>Import</TALLYREQUEST><TYPE>Data</TYPE><ID>All Masters</ID></HEADER><BODY><DESC><STATICVARIABLES><SVCURRENTCOMPANY>{self._xml_escape(company)}</SVCURRENTCOMPANY></STATICVARIABLES></DESC><DATA><TALLYMESSAGE xmlns:UDF="TallyUDF"><LEDGER NAME="{self._xml_escape(name)}" ACTION="Alter"><NAME>{self._xml_escape(name)}</NAME><OPENINGBALANCE>{amount:.2f}</OPENINGBALANCE></LEDGER></TALLYMESSAGE></DATA></BODY></ENVELOPE>"""
result=self._opening_master_import_result(self._post_xml(xml),label=f"Ledger opening-balance alteration for '{name}'")
result["request_xml"]=xml
return result
def alter_stock_item_opening(self, company_name: str, *, stock_item_name: str, opening_qty: float, unit: str, opening_value: float, opening_rate: float) -> dict:
company, name, unit_name = str(company_name or "").strip(), str(stock_item_name or "").strip(), str(unit or "").strip()
if not company or not name: raise ValueError("Company and Stock Item name are required.")
if not unit_name: raise ValueError(f"Stock Item '{name}' has no current Tally unit; opening quantity cannot be altered safely.")
qty=float(opening_qty or 0); value=float(opening_value or 0); rate=abs(float(opening_rate or 0))
qty_text=f"{qty:.6f}".rstrip("0").rstrip("."); rate_text=f"{rate:.6f}".rstrip("0").rstrip(".")
xml=f"""<ENVELOPE><HEADER><VERSION>1</VERSION><TALLYREQUEST>Import</TALLYREQUEST><TYPE>Data</TYPE><ID>All Masters</ID></HEADER><BODY><DESC><STATICVARIABLES><SVCURRENTCOMPANY>{self._xml_escape(company)}</SVCURRENTCOMPANY></STATICVARIABLES></DESC><DATA><TALLYMESSAGE xmlns:UDF="TallyUDF"><STOCKITEM NAME="{self._xml_escape(name)}" ACTION="Alter"><NAME>{self._xml_escape(name)}</NAME><OPENINGBALANCE>{qty_text} {self._xml_escape(unit_name)}</OPENINGBALANCE><OPENINGVALUE>{value:.2f}</OPENINGVALUE><OPENINGRATE>{rate_text}/{self._xml_escape(unit_name)}</OPENINGRATE></STOCKITEM></TALLYMESSAGE></DATA></BODY></ENVELOPE>"""
result=self._opening_master_import_result(self._post_xml(xml),label=f"Stock Item opening-balance alteration for '{name}'")
result["request_xml"]=xml
return result
def purchase_posting_ledgers(self, company_name: str) -> list[dict]:
company = str(company_name or "").strip()
if not company:
@@ -1088,7 +1124,9 @@ class TallyLiveConnector:
"opening_balance": _to_number(_child_text(element, "OPENINGBALANCE")),
"closing_balance": _to_number(_child_text(element, "CLOSINGBALANCE")),
"opening_value": _to_number(_child_text(element, "OPENINGVALUE")),
"closing_value": _to_number(_child_text(element, "CLOSINGVALUE")),
"opening_rate": _child_text(element, "OPENINGRATE"),
"closing_rate": _child_text(element, "CLOSINGRATE"),
"numbering_method": _child_text(element, "NUMBERINGMETHOD"),
"tax_type": _child_text(element, "TAXTYPE"),
"gst_applicable": _child_text(element, "GSTAPPLICABLE"),