Add Phase 21A opening balance comparison and controlled Tally correction
This commit is contained in:
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.21.0"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.21.1"
|
||||
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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user