Add Phase 11 bank to Tally with multi-bank contra intelligence
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.13.1"
|
||||
__version__ = "1.14.0"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -43,6 +43,10 @@ class AgentCommandProcessor:
|
||||
result = self._purchase_posting_preflight(payload)
|
||||
elif action == "accounting_post_purchase_voucher":
|
||||
result = self._post_purchase_voucher(payload)
|
||||
elif action == "accounting_bank_posting_preflight":
|
||||
result = self._bank_posting_preflight(payload)
|
||||
elif action == "accounting_post_bank_voucher":
|
||||
result = self._post_bank_voucher(payload)
|
||||
elif action == "accounting_depreciation_preview":
|
||||
result = self._depreciation_preview(payload)
|
||||
elif action == "accounting_calculate_it_depreciation":
|
||||
@@ -369,6 +373,75 @@ class AgentCommandProcessor:
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
|
||||
def _bank_posting_preflight(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
company, company_name = self._resolve_open_company(payload)
|
||||
ledgers = self.tally.bank_posting_ledgers(company_name)
|
||||
duplicates = self.tally.find_bank_voucher_duplicates(
|
||||
company_name,
|
||||
voucher_date=str(payload.get("transaction_date") or ""),
|
||||
voucher_type=str(payload.get("voucher_type") or ""),
|
||||
reference=str(payload.get("reference") or ""),
|
||||
amount=float(payload.get("amount") or 0),
|
||||
)
|
||||
return {
|
||||
**ledgers,
|
||||
"duplicate_candidates": duplicates,
|
||||
"company_name": company_name,
|
||||
"company_guid": str(company.get("guid") or ""),
|
||||
"agent": self._agent_info(),
|
||||
}
|
||||
|
||||
def _post_bank_voucher(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
company, company_name = self._resolve_open_company(payload)
|
||||
ledgers = self.tally.bank_posting_ledgers(company_name)
|
||||
all_names = {str(x.get("name") or "").strip().casefold() for x in ledgers.get("all_ledgers") or []}
|
||||
bank_names = {str(x.get("name") or "").strip().casefold() for x in ledgers.get("bank_ledgers") or []}
|
||||
|
||||
bank = str(payload.get("bank_ledger_name") or "").strip()
|
||||
counter = str(payload.get("counter_ledger_name") or "").strip()
|
||||
other = str(payload.get("other_bank_ledger_name") or "").strip()
|
||||
vtype = str(payload.get("voucher_type") or "").title()
|
||||
|
||||
if bank.casefold() not in bank_names:
|
||||
raise ValueError("Selected bank ledger is not present under Tally bank accounts.")
|
||||
if vtype == "Contra":
|
||||
if other.casefold() not in bank_names or other.casefold() == bank.casefold():
|
||||
raise ValueError("The other bank ledger is invalid for Contra.")
|
||||
elif counter.casefold() not in all_names:
|
||||
raise ValueError("Selected counter ledger is not present in Tally.")
|
||||
|
||||
duplicates = self.tally.find_bank_voucher_duplicates(
|
||||
company_name,
|
||||
voucher_date=str(payload.get("transaction_date") or ""),
|
||||
voucher_type=vtype,
|
||||
reference=str(payload.get("reference") or ""),
|
||||
amount=float(payload.get("amount") or 0),
|
||||
)
|
||||
if duplicates:
|
||||
raise ValueError("Possible duplicate bank voucher already exists in Tally. Posting blocked.")
|
||||
|
||||
result = self.tally.post_bank_voucher(
|
||||
company_name,
|
||||
voucher_type=vtype,
|
||||
voucher_date=str(payload.get("transaction_date") or ""),
|
||||
bank_ledger_name=bank,
|
||||
counter_ledger_name=counter,
|
||||
other_bank_ledger_name=other,
|
||||
direction=str(payload.get("direction") or ""),
|
||||
amount=float(payload.get("amount") or 0),
|
||||
narration=str(payload.get("narration") or ""),
|
||||
reference=str(payload.get("reference") or ""),
|
||||
)
|
||||
return {
|
||||
"posted": True,
|
||||
"company_name": company_name,
|
||||
"company_guid": str(company.get("guid") or ""),
|
||||
"reference": str(payload.get("reference") or ""),
|
||||
"tally_result": 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()
|
||||
|
||||
@@ -838,6 +838,101 @@ class TallyLiveConnector:
|
||||
"request_xml": request_xml,
|
||||
}
|
||||
|
||||
|
||||
def bank_posting_ledgers(self, company_name: str) -> dict:
|
||||
rows = self.purchase_posting_ledgers(company_name)
|
||||
bank_rows = [
|
||||
row for row in rows
|
||||
if "BANK" in str(row.get("parent") or "").upper()
|
||||
or "BANK ACCOUNT" in str(row.get("parent") or "").upper()
|
||||
]
|
||||
return {"bank_ledgers": bank_rows, "all_ledgers": rows}
|
||||
|
||||
def find_bank_voucher_duplicates(self, company_name: str, *, voucher_date: str, voucher_type: str, reference: str, amount: float) -> list[dict]:
|
||||
target_ref = re.sub(r"[^A-Z0-9]", "", str(reference or "").upper())
|
||||
expected = round(abs(float(amount or 0)), 2)
|
||||
result = []
|
||||
for voucher in self.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(x.get("amount") or 0)) for x in voucher.get("ledger_entries") or []]
|
||||
if expected and values and all(abs(v-expected) > max(1.0, expected*0.002) for v in values):
|
||||
continue
|
||||
result.append({
|
||||
"guid": voucher.get("guid") or "",
|
||||
"voucher_number": voucher.get("voucher_number") or "",
|
||||
"reference": voucher.get("reference") or "",
|
||||
"date": voucher.get("date") or "",
|
||||
})
|
||||
return result[:20]
|
||||
|
||||
def build_bank_voucher_xml(self, company_name: str, *, voucher_type: str, voucher_date: str, bank_ledger_name: str, counter_ledger_name: str = "", other_bank_ledger_name: str = "", direction: str = "", amount: float, narration: str = "", reference: str = "") -> str:
|
||||
vtype = str(voucher_type or "").title()
|
||||
if vtype not in {"Payment", "Receipt", "Contra"}:
|
||||
raise ValueError("Unsupported bank voucher type.")
|
||||
company = str(company_name or "").strip()
|
||||
bank = str(bank_ledger_name or "").strip()
|
||||
counter = str(counter_ledger_name or "").strip()
|
||||
other_bank = str(other_bank_ledger_name or "").strip()
|
||||
value = round(abs(float(amount or 0)), 2)
|
||||
if not company or not bank or value <= 0:
|
||||
raise ValueError("Company, bank ledger and positive amount are required.")
|
||||
if vtype == "Contra" and not other_bank:
|
||||
raise ValueError("Other bank ledger is required for Contra.")
|
||||
if vtype != "Contra" and not counter:
|
||||
raise ValueError("Counter ledger is required for Payment/Receipt.")
|
||||
|
||||
if vtype == "Payment":
|
||||
debit_ledger, credit_ledger = counter, bank
|
||||
elif vtype == "Receipt":
|
||||
debit_ledger, credit_ledger = bank, counter
|
||||
else:
|
||||
# Row direction describes movement in the uploaded bank account.
|
||||
if str(direction or "").lower() == "debit":
|
||||
debit_ledger, credit_ledger = other_bank, bank
|
||||
else:
|
||||
debit_ledger, credit_ledger = bank, other_bank
|
||||
|
||||
ref_xml = f"<REFERENCE>{self._xml_escape(reference)}</REFERENCE>" if reference else ""
|
||||
return f"""<ENVELOPE>
|
||||
<HEADER><VERSION>1</VERSION><TALLYREQUEST>Import</TALLYREQUEST><TYPE>Data</TYPE><ID>Vouchers</ID></HEADER>
|
||||
<BODY><DESC><STATICVARIABLES><SVCURRENTCOMPANY>{self._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._xml_escape(narration)}</NARRATION>
|
||||
<ALLLEDGERENTRIES.LIST><LEDGERNAME>{self._xml_escape(debit_ledger)}</LEDGERNAME><ISDEEMEDPOSITIVE>Yes</ISDEEMEDPOSITIVE><AMOUNT>-{value:.2f}</AMOUNT></ALLLEDGERENTRIES.LIST>
|
||||
<ALLLEDGERENTRIES.LIST><LEDGERNAME>{self._xml_escape(credit_ledger)}</LEDGERNAME><ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE><AMOUNT>{value:.2f}</AMOUNT></ALLLEDGERENTRIES.LIST>
|
||||
</VOUCHER>
|
||||
</TALLYMESSAGE></DATA></BODY>
|
||||
</ENVELOPE>"""
|
||||
|
||||
def post_bank_voucher(self, company_name: str, **kwargs) -> dict:
|
||||
request_xml = self.build_bank_voucher_xml(company_name, **kwargs)
|
||||
response_xml = self._post_xml(request_xml)
|
||||
root = ET.fromstring(_clean_xml_response(response_xml).encode("utf-8"))
|
||||
def _i(tag):
|
||||
try: return int(float(_first_text(root, [tag]) or 0))
|
||||
except Exception: return 0
|
||||
errors = _i("ERRORS")
|
||||
line_error = _first_text(root, ["LINEERROR"])
|
||||
created = _i("CREATED")
|
||||
if errors or line_error:
|
||||
raise ValueError(line_error or f"Tally reported {errors} bank voucher error(s).")
|
||||
if created < 1 and _i("ALTERED") < 1:
|
||||
raise ValueError("Tally did not confirm creation of the bank voucher.")
|
||||
return {
|
||||
"created": created,
|
||||
"errors": errors,
|
||||
"last_voucher_id": str(_first_text(root, ["LASTVCHID","LASTMID","LASTVOUCHERID"]) or ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_vouchers(xml_text: str) -> list[dict]:
|
||||
cleaned = _clean_xml_response(xml_text)
|
||||
|
||||
Reference in New Issue
Block a user