Add Phase 18 automatic stock item voucher preparation

This commit is contained in:
A R R R Associates
2026-08-23 18:45:21 +05:30
parent 1111a52917
commit 67ebd587e9
13 changed files with 1705 additions and 31 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.17.0"
ERP_LOCAL_AGENT_VERSION = "1.18.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.17.0"
__version__ = "1.18.0"
AGENT_NAME = "ERP Local Agent"
@@ -83,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, "native_voucher_engine_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,
"tally_transaction_sync_capability": True,
"historical_learning_read_capability": True,
"purchase_posting_preflight_capability": True,
@@ -571,6 +571,7 @@ class AgentCommandProcessor:
reference=str(payload.get("reference") or ""),
total_amount=float(payload.get("total_amount") or 0),
lines=list(payload.get("lines") or []),
items=list(payload.get("items") or []),
)
return {
**result,
@@ -590,6 +591,7 @@ class AgentCommandProcessor:
reference=str(payload.get("reference") or ""),
total_amount=float(payload.get("total_amount") or 0),
lines=list(payload.get("lines") or []),
items=list(payload.get("items") or []),
)
if preflight.get("duplicate_candidates"):
raise ValueError("Possible duplicate native Tally voucher exists. Posting blocked.")
@@ -601,6 +603,7 @@ class AgentCommandProcessor:
reference=str(payload.get("reference") or ""),
narration=str(payload.get("narration") or ""),
lines=list(payload.get("lines") or []),
items=list(payload.get("items") or []),
)
safe_result = {
key: value
@@ -40,11 +40,10 @@ def _tally_date(value):
class NativeVoucherEngine:
"""Native Tally accounting-voucher writer layered over the existing connector.
"""Native Tally accounting-voucher writer.
It intentionally reuses the current Tally connector's XML transport,
ledger discovery and voucher export functions so no existing posting
behaviour is replaced.
Phase 18 preserves the Phase 17 ledger-voucher behaviour and adds true
stock-item inventory allocations for Purchase/Sales vouchers.
"""
def __init__(self, tally_connector):
@@ -78,9 +77,7 @@ class NativeVoucherEngine:
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()
actual_name = str(available[ledger_name.casefold()].get("name") or ledger_name).strip()
if side == "DR":
total_dr += amount
@@ -90,6 +87,7 @@ class NativeVoucherEngine:
normalized.append(
{
"ledger_name": actual_name,
"ledger_role_code": str(line.get("ledger_role_code") or "").strip(),
"dr_cr": side,
"amount": amount,
}
@@ -109,6 +107,131 @@ class NativeVoucherEngine:
return normalized, total_dr, total_cr
def _stock_item_names(self, company_name: str):
request_xml = f"""<ENVELOPE>
<HEADER>
<VERSION>1</VERSION>
<TALLYREQUEST>Export</TALLYREQUEST>
<TYPE>Collection</TYPE>
<ID>ERPStockItemCollection</ID>
</HEADER>
<BODY>
<DESC>
<STATICVARIABLES>
<SVCURRENTCOMPANY>{self.tally._xml_escape(company_name)}</SVCURRENTCOMPANY>
<SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT>
</STATICVARIABLES>
<TDL>
<TDLMESSAGE>
<COLLECTION NAME="ERPStockItemCollection" ISMODIFY="No">
<TYPE>Stock Item</TYPE>
<FETCH>Name,BaseUnits,GSTApplicable,GSTTypeOfSupply,HSNCode</FETCH>
</COLLECTION>
</TDLMESSAGE>
</TDL>
</DESC>
</BODY>
</ENVELOPE>"""
response = self.tally._post_xml(request_xml)
root = ET.fromstring(_clean_xml_response(response).encode("utf-8"))
names = {}
for element in root.iter():
if _tag(element) in {"STOCKITEM", "STOCKITEM.LIST"}:
name = ""
unit = ""
for child in element.iter():
ctag = _tag(child)
value = (child.text or "").strip()
if not value:
continue
if ctag in {"NAME", "STOCKITEMNAME"} and not name:
name = value
elif ctag in {"BASEUNITS", "BASEUNIT"} and not unit:
unit = value
if name:
names[name.casefold()] = {"name": name, "unit": unit}
return names
def _normalize_items(self, company_name: str, voucher_type: str, items: list[dict]):
if not items:
return []
vtype = str(voucher_type or "").strip().title()
if vtype not in {"Purchase", "Sales"}:
raise ValueError("Stock-item lines are permitted only in Purchase or Sales vouchers.")
available = self._stock_item_names(company_name)
normalized = []
for index, item in enumerate(items or []):
source_name = str(item.get("stock_item_name") or "").strip()
if not source_name:
raise ValueError(f"Stock item line {index + 1}: item name is required.")
matched = available.get(source_name.casefold())
if matched is None:
# Conservative token matching only when it produces exactly one result.
source_tokens = {
token for token in re.split(r"[^A-Z0-9]+", source_name.upper())
if len(token) >= 4
}
candidates = []
for key, row in available.items():
name_tokens = {
token for token in re.split(r"[^A-Z0-9]+", row["name"].upper())
if len(token) >= 4
}
common = source_tokens & name_tokens
if common:
candidates.append((len(common), row))
candidates.sort(key=lambda pair: (-pair[0], pair[1]["name"]))
if candidates:
top_score = candidates[0][0]
top = [row for score, row in candidates if score == top_score]
if len(top) == 1:
matched = top[0]
if matched is None:
raise ValueError(
f"Stock item '{source_name}' does not exist in the open Tally company. "
"Create/map the stock item before posting; Phase 18 will not create masters automatically."
)
qty = abs(float(item.get("quantity") or 0))
rate = abs(float(item.get("rate") or 0))
amount = round(abs(float(item.get("amount") or 0)), 2)
unit = str(item.get("unit") or matched.get("unit") or "").strip()
accounting_ledger = str(item.get("accounting_ledger_name") or "").strip()
godown = str(item.get("godown_name") or "").strip()
side = str(item.get("dr_cr") or ("DR" if vtype == "Purchase" else "CR")).upper()
if amount <= 0:
raise ValueError(f"Stock item line {index + 1}: amount must be greater than zero.")
if qty < 0:
raise ValueError(f"Stock item line {index + 1}: quantity is invalid.")
if not accounting_ledger:
raise ValueError(f"Stock item line {index + 1}: accounting ledger is required.")
if side not in {"DR", "CR"}:
raise ValueError(f"Stock item line {index + 1}: DR/CR is invalid.")
if not rate and qty:
rate = amount / qty
normalized.append(
{
"stock_item_name": matched["name"],
"quantity": qty,
"unit": unit,
"rate": rate,
"amount": amount,
"dr_cr": side,
"accounting_ledger_name": accounting_ledger,
"godown_name": godown,
}
)
return normalized
def find_duplicates(
self,
company_name: str,
@@ -160,12 +283,14 @@ class NativeVoucherEngine:
reference: str,
total_amount: float,
lines: list[dict],
items: list[dict] | None = None,
):
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)
normalized_items = self._normalize_items(company_name, vtype, list(items or []))
duplicates = self.find_duplicates(
company_name,
voucher_type=vtype,
@@ -177,12 +302,80 @@ class NativeVoucherEngine:
return {
"voucher_type": vtype,
"line_count": len(normalized),
"stock_item_count": len(normalized_items),
"total_debit": total_dr,
"total_credit": total_cr,
"duplicate_candidates": duplicates,
"validated_ledgers": [row["ledger_name"] for row in normalized],
"validated_stock_items": [row["stock_item_name"] for row in normalized_items],
}
def _ledger_xml(self, row):
deemed_positive = "Yes" if row["dr_cr"] == "DR" else "No"
signed_amount = (
f"-{row['amount']:.2f}"
if row["dr_cr"] == "DR"
else f"{row['amount']:.2f}"
)
return (
"<ALLLEDGERENTRIES.LIST>"
f"<LEDGERNAME>{self.tally._xml_escape(row['ledger_name'])}</LEDGERNAME>"
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
f"<AMOUNT>{signed_amount}</AMOUNT>"
"</ALLLEDGERENTRIES.LIST>"
)
def _inventory_xml(self, item):
deemed_positive = "Yes" if item["dr_cr"] == "DR" else "No"
signed_amount = (
f"-{item['amount']:.2f}"
if item["dr_cr"] == "DR"
else f"{item['amount']:.2f}"
)
quantity_xml = ""
rate_xml = ""
if item["quantity"] and item["unit"]:
qty_text = f"{item['quantity']:g} {self.tally._xml_escape(item['unit'])}"
quantity_xml = (
f"<ACTUALQTY>{qty_text}</ACTUALQTY>"
f"<BILLEDQTY>{qty_text}</BILLEDQTY>"
)
if item["rate"] and item["unit"]:
rate_xml = (
f"<RATE>{item['rate']:g}/{self.tally._xml_escape(item['unit'])}</RATE>"
)
godown_xml = ""
if item["godown_name"]:
godown_xml = (
"<BATCHALLOCATIONS.LIST>"
f"<GODOWNNAME>{self.tally._xml_escape(item['godown_name'])}</GODOWNNAME>"
f"<AMOUNT>{signed_amount}</AMOUNT>"
f"{quantity_xml}"
"</BATCHALLOCATIONS.LIST>"
)
accounting_xml = (
"<ACCOUNTINGALLOCATIONS.LIST>"
f"<LEDGERNAME>{self.tally._xml_escape(item['accounting_ledger_name'])}</LEDGERNAME>"
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
f"<AMOUNT>{signed_amount}</AMOUNT>"
"</ACCOUNTINGALLOCATIONS.LIST>"
)
return (
"<ALLINVENTORYENTRIES.LIST>"
f"<STOCKITEMNAME>{self.tally._xml_escape(item['stock_item_name'])}</STOCKITEMNAME>"
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
f"{rate_xml}"
f"<AMOUNT>{signed_amount}</AMOUNT>"
f"{quantity_xml}"
f"{godown_xml}"
f"{accounting_xml}"
"</ALLINVENTORYENTRIES.LIST>"
)
def build_xml(
self,
company_name: str,
@@ -192,6 +385,7 @@ class NativeVoucherEngine:
reference: str,
narration: str,
lines: list[dict],
items: list[dict] | None = None,
):
vtype = str(voucher_type or "").strip().title()
if vtype not in VALID_VOUCHER_TYPES:
@@ -202,29 +396,31 @@ class NativeVoucherEngine:
raise ValueError("Tally company name is required.")
normalized, total_dr, total_cr = self._normalize_lines(company, lines)
normalized_items = self._normalize_items(company, vtype, list(items or []))
entries = []
# When stock items are present, their purchase/sales accounting ledger
# allocation is carried inside ALLINVENTORYENTRIES.LIST. Do not duplicate
# that same accounting ledger as an ALLLEDGERENTRIES amount.
item_ledger_names = {
item["accounting_ledger_name"].casefold()
for item in normalized_items
if item["accounting_ledger_name"]
}
ledger_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}"
if normalized_items and row["ledger_name"].casefold() in item_ledger_names:
continue
ledger_entries.append(self._ledger_xml(row))
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>"
)
inventory_entries = [self._inventory_xml(item) for item in normalized_items]
ref_xml = (
f"<REFERENCE>{self.tally._xml_escape(reference)}</REFERENCE>"
if str(reference or "").strip()
else ""
)
voucher_view = "Invoice Voucher View" if normalized_items else "Accounting Voucher View"
invoice_xml = "<ISINVOICE>Yes</ISINVOICE>" if normalized_items else ""
return f"""<ENVELOPE>
<HEADER>
@@ -241,13 +437,15 @@ class NativeVoucherEngine:
</DESC>
<DATA>
<TALLYMESSAGE xmlns:UDF="TallyUDF">
<VOUCHER VCHTYPE="{vtype}" ACTION="Create" OBJVIEW="Accounting Voucher View">
<VOUCHER VCHTYPE="{vtype}" ACTION="Create" OBJVIEW="{voucher_view}">
<DATE>{_tally_date(voucher_date)}</DATE>
<VOUCHERTYPENAME>{vtype}</VOUCHERTYPENAME>
<PERSISTEDVIEW>Accounting Voucher View</PERSISTEDVIEW>
<PERSISTEDVIEW>{voucher_view}</PERSISTEDVIEW>
{invoice_xml}
{ref_xml}
<NARRATION>{self.tally._xml_escape(narration)}</NARRATION>
{''.join(entries)}
{''.join(ledger_entries)}
{''.join(inventory_entries)}
</VOUCHER>
</TALLYMESSAGE>
</DATA>
@@ -263,6 +461,7 @@ class NativeVoucherEngine:
reference: str,
narration: str,
lines: list[dict],
items: list[dict] | None = None,
):
request_xml = self.build_xml(
company_name,
@@ -271,6 +470,7 @@ class NativeVoucherEngine:
reference=reference,
narration=narration,
lines=lines,
items=items,
)
response_xml = self.tally._post_xml(request_xml)
root = ET.fromstring(_clean_xml_response(response_xml).encode("utf-8"))