Add Phase 17 common native Tally voucher engine

This commit is contained in:
A R R R Associates
2026-08-23 15:26:37 +05:30
parent 7d11236eed
commit 1111a52917
11 changed files with 1809 additions and 3 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.16.0"
ERP_LOCAL_AGENT_VERSION = "1.17.0"
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.16.0"
__version__ = "1.17.0"
AGENT_NAME = "ERP Local Agent"
@@ -7,6 +7,7 @@ from typing import Any
from . import __version__
from .accounting_store import LocalAccountingStore
from .tally import TallyLiveConnector
from .native_voucher_engine import NativeVoucherEngine
class AgentCommandProcessor:
@@ -49,6 +50,10 @@ class AgentCommandProcessor:
result = self._bank_posting_preflight(payload)
elif action == "accounting_post_bank_voucher":
result = self._post_bank_voucher(payload)
elif action == "accounting_native_voucher_preflight":
result = self._native_voucher_preflight(payload)
elif action == "accounting_post_native_voucher":
result = self._post_native_voucher(payload)
elif action == "accounting_depreciation_preview":
result = self._depreciation_preview(payload)
elif action == "accounting_calculate_it_depreciation":
@@ -78,7 +83,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,
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True,
"tally_transaction_sync_capability": True,
"historical_learning_read_capability": True,
"purchase_posting_preflight_capability": True,
@@ -555,6 +560,73 @@ class AgentCommandProcessor:
"agent": self._agent_info(),
}
def _native_voucher_preflight(self, payload: dict[str, Any]) -> dict[str, Any]:
company, company_name = self._resolve_open_company(payload)
engine = NativeVoucherEngine(self.tally)
result = engine.preflight(
company_name,
voucher_type=str(payload.get("voucher_type") or ""),
voucher_date=str(payload.get("voucher_date") or ""),
reference=str(payload.get("reference") or ""),
total_amount=float(payload.get("total_amount") or 0),
lines=list(payload.get("lines") or []),
)
return {
**result,
"company_name": company_name,
"company_guid": str(company.get("guid") or ""),
"agent": self._agent_info(),
}
def _post_native_voucher(self, payload: dict[str, Any]) -> dict[str, Any]:
company, company_name = self._resolve_open_company(payload)
engine = NativeVoucherEngine(self.tally)
preflight = engine.preflight(
company_name,
voucher_type=str(payload.get("voucher_type") or ""),
voucher_date=str(payload.get("voucher_date") or ""),
reference=str(payload.get("reference") or ""),
total_amount=float(payload.get("total_amount") or 0),
lines=list(payload.get("lines") or []),
)
if preflight.get("duplicate_candidates"):
raise ValueError("Possible duplicate native Tally voucher exists. Posting blocked.")
result = engine.post(
company_name,
voucher_type=str(payload.get("voucher_type") or ""),
voucher_date=str(payload.get("voucher_date") or ""),
reference=str(payload.get("reference") or ""),
narration=str(payload.get("narration") or ""),
lines=list(payload.get("lines") or []),
)
safe_result = {
key: value
for key, value in result.items()
if key not in {"raw_response", "request_xml"}
}
self.logger.warning(
"CONTROLLED NATIVE TALLY WRITEBACK posted client_id=%s company=%s "
"erp_voucher_draft_id=%s voucher_type=%s amount=%.2f tally_voucher=%s",
payload.get("client_id"),
company_name,
payload.get("erp_voucher_draft_id"),
str(payload.get("voucher_type") or ""),
float(payload.get("total_amount") or 0),
safe_result.get("last_voucher_id"),
)
return {
"posted": True,
"company_name": company_name,
"company_guid": str(company.get("guid") or ""),
"voucher_type": str(payload.get("voucher_type") or "").title(),
"reference": str(payload.get("reference") or ""),
"tally_result": safe_result,
"agent": self._agent_info(),
}
def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]:
client_id = int(payload.get("client_id"))
tally_guid = str(payload.get("tally_guid") or "").strip()
@@ -0,0 +1,305 @@
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
VALID_VOUCHER_TYPES = {"Purchase", "Sales", "Receipt", "Payment", "Contra", "Journal"}
def _tag(element):
return element.tag.split("}")[-1].upper()
def _first_text(root, names):
wanted = {str(name).upper() for name in names}
for element in root.iter():
if _tag(element) in wanted:
text = (element.text or "").strip()
if text:
return text
return ""
def _clean_xml_response(text):
value = str(text or "")
start = value.find("<")
return value[start:] if start >= 0 else value
def _tally_date(value):
digits = re.sub(r"[^0-9]", "", str(value or ""))
if len(digits) == 8 and str(value).startswith(tuple(str(y) for y in range(1900, 2200))):
return digits
parts = re.split(r"[-/]", str(value or "").strip())
if len(parts) == 3:
if len(parts[0]) == 4:
return f"{parts[0]}{parts[1].zfill(2)}{parts[2].zfill(2)}"
return f"{parts[2]}{parts[1].zfill(2)}{parts[0].zfill(2)}"
raise ValueError("Voucher date must be a valid YYYY-MM-DD date.")
class NativeVoucherEngine:
"""Native Tally accounting-voucher writer layered over the existing connector.
It intentionally reuses the current Tally connector's XML transport,
ledger discovery and voucher export functions so no existing posting
behaviour is replaced.
"""
def __init__(self, tally_connector):
self.tally = tally_connector
def _normalize_lines(self, company_name: str, lines: list[dict]):
ledger_rows = self.tally.purchase_posting_ledgers(company_name)
available = {
str(row.get("name") or "").strip().casefold(): row
for row in ledger_rows
if str(row.get("name") or "").strip()
}
normalized = []
total_dr = 0.0
total_cr = 0.0
for index, line in enumerate(lines or []):
ledger_name = str(line.get("ledger_name") or "").strip()
side = str(line.get("dr_cr") or "").strip().upper()
amount = round(abs(float(line.get("amount") or 0)), 2)
if not ledger_name:
raise ValueError(f"Voucher line {index + 1}: ledger is required.")
if ledger_name.casefold() not in available:
raise ValueError(
f"Voucher line {index + 1}: ledger '{ledger_name}' does not exist in the open Tally company."
)
if side not in {"DR", "CR"}:
raise ValueError(f"Voucher line {index + 1}: DR/CR is invalid.")
if amount <= 0:
raise ValueError(f"Voucher line {index + 1}: amount must be greater than zero.")
actual_name = str(
available[ledger_name.casefold()].get("name") or ledger_name
).strip()
if side == "DR":
total_dr += amount
else:
total_cr += amount
normalized.append(
{
"ledger_name": actual_name,
"dr_cr": side,
"amount": amount,
}
)
total_dr = round(total_dr, 2)
total_cr = round(total_cr, 2)
if len(normalized) < 2:
raise ValueError("At least two voucher lines are required.")
if total_dr <= 0 or total_cr <= 0:
raise ValueError("Voucher requires at least one Debit and one Credit.")
if abs(total_dr - total_cr) > 0.01:
raise ValueError(
f"Native voucher is not balanced. Debit={total_dr:.2f}, Credit={total_cr:.2f}."
)
return normalized, total_dr, total_cr
def find_duplicates(
self,
company_name: str,
*,
voucher_type: str,
voucher_date: str,
reference: str,
amount: float,
):
target_ref = re.sub(r"[^A-Z0-9]", "", str(reference or "").upper())
expected = round(abs(float(amount or 0)), 2)
matches = []
for voucher in self.tally.export_vouchers(company_name, voucher_date, voucher_date):
if str(voucher.get("voucher_type_name") or "").strip().casefold() != str(voucher_type or "").strip().casefold():
continue
ref = re.sub(r"[^A-Z0-9]", "", str(voucher.get("reference") or "").upper())
if target_ref and ref and ref != target_ref:
continue
values = [
abs(float(entry.get("amount") or 0))
for entry in (voucher.get("ledger_entries") or [])
]
if expected and values and all(
abs(value - expected) > max(1.0, expected * 0.002)
for value in values
):
continue
matches.append(
{
"guid": voucher.get("guid") or "",
"voucher_number": voucher.get("voucher_number") or "",
"reference": voucher.get("reference") or "",
"date": voucher.get("date") or "",
}
)
return matches[:20]
def preflight(
self,
company_name: str,
*,
voucher_type: str,
voucher_date: str,
reference: str,
total_amount: float,
lines: list[dict],
):
vtype = str(voucher_type or "").strip().title()
if vtype not in VALID_VOUCHER_TYPES:
raise ValueError("Unsupported native Tally voucher type.")
normalized, total_dr, total_cr = self._normalize_lines(company_name, lines)
duplicates = self.find_duplicates(
company_name,
voucher_type=vtype,
voucher_date=voucher_date,
reference=reference,
amount=float(total_amount or total_dr),
)
return {
"voucher_type": vtype,
"line_count": len(normalized),
"total_debit": total_dr,
"total_credit": total_cr,
"duplicate_candidates": duplicates,
"validated_ledgers": [row["ledger_name"] for row in normalized],
}
def build_xml(
self,
company_name: str,
*,
voucher_type: str,
voucher_date: str,
reference: str,
narration: str,
lines: list[dict],
):
vtype = str(voucher_type or "").strip().title()
if vtype not in VALID_VOUCHER_TYPES:
raise ValueError("Unsupported native Tally voucher type.")
company = str(company_name or "").strip()
if not company:
raise ValueError("Tally company name is required.")
normalized, total_dr, total_cr = self._normalize_lines(company, lines)
entries = []
for row in normalized:
if row["dr_cr"] == "DR":
deemed_positive = "Yes"
signed_amount = f"-{row['amount']:.2f}"
else:
deemed_positive = "No"
signed_amount = f"{row['amount']:.2f}"
entries.append(
"<ALLLEDGERENTRIES.LIST>"
f"<LEDGERNAME>{self.tally._xml_escape(row['ledger_name'])}</LEDGERNAME>"
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
f"<AMOUNT>{signed_amount}</AMOUNT>"
"</ALLLEDGERENTRIES.LIST>"
)
ref_xml = (
f"<REFERENCE>{self.tally._xml_escape(reference)}</REFERENCE>"
if str(reference or "").strip()
else ""
)
return f"""<ENVELOPE>
<HEADER>
<VERSION>1</VERSION>
<TALLYREQUEST>Import</TALLYREQUEST>
<TYPE>Data</TYPE>
<ID>Vouchers</ID>
</HEADER>
<BODY>
<DESC>
<STATICVARIABLES>
<SVCURRENTCOMPANY>{self.tally._xml_escape(company)}</SVCURRENTCOMPANY>
</STATICVARIABLES>
</DESC>
<DATA>
<TALLYMESSAGE xmlns:UDF="TallyUDF">
<VOUCHER VCHTYPE="{vtype}" ACTION="Create" OBJVIEW="Accounting Voucher View">
<DATE>{_tally_date(voucher_date)}</DATE>
<VOUCHERTYPENAME>{vtype}</VOUCHERTYPENAME>
<PERSISTEDVIEW>Accounting Voucher View</PERSISTEDVIEW>
{ref_xml}
<NARRATION>{self.tally._xml_escape(narration)}</NARRATION>
{''.join(entries)}
</VOUCHER>
</TALLYMESSAGE>
</DATA>
</BODY>
</ENVELOPE>"""
def post(
self,
company_name: str,
*,
voucher_type: str,
voucher_date: str,
reference: str,
narration: str,
lines: list[dict],
):
request_xml = self.build_xml(
company_name,
voucher_type=voucher_type,
voucher_date=voucher_date,
reference=reference,
narration=narration,
lines=lines,
)
response_xml = self.tally._post_xml(request_xml)
root = ET.fromstring(_clean_xml_response(response_xml).encode("utf-8"))
def _int(tag):
try:
return int(float(_first_text(root, [tag]) or 0))
except Exception:
return 0
errors = _int("ERRORS")
line_error = _first_text(root, ["LINEERROR"])
created = _int("CREATED")
altered = _int("ALTERED")
if errors or line_error:
raise ValueError(
line_error or f"Tally reported {errors} native voucher error(s)."
)
if created < 1 and altered < 1:
raise ValueError("Tally did not confirm creation of the native voucher.")
return {
"created": created,
"altered": altered,
"errors": errors,
"last_voucher_id": str(
_first_text(root, ["LASTVCHID", "LASTMID", "LASTVOUCHERID"]) or ""
),
"raw_response": response_xml,
"request_xml": request_xml,
}