Add Phase 18 automatic stock item voucher preparation
This commit is contained in:
@@ -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
|
||||
|
||||
+224
-24
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user