Add Phase 17 common native Tally voucher engine
This commit is contained in:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user