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
@@ -0,0 +1,376 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from sqlalchemy import select
from app.modules.accounting.chart_models import AccountingChartLedger
from app.modules.accounting.chart_service import effective_role
from app.modules.accounting.voucher_engine_models import (
AccountingVoucherDraft,
AccountingVoucherItemLine,
AccountingVoucherLine,
)
def _s(value):
return str(value or "").strip()
def _utcnow():
return datetime.now(timezone.utc)
def _ledger_candidates(db, *, tenant_id: int, client_id: int, tally_guid: str):
rows = list(
db.execute(
select(AccountingChartLedger).where(
AccountingChartLedger.tenant_id == int(tenant_id),
AccountingChartLedger.client_id == int(client_id),
AccountingChartLedger.tally_guid == _s(tally_guid),
)
).scalars().all()
)
return rows
def _match_by_role(rows, roles, name_hint=""):
roles = set(roles)
hint = _s(name_hint).casefold()
ranked = []
for row in rows:
role = effective_role(row)
if role not in roles:
continue
score = 100
if hint:
name = row.name.casefold()
if hint == name:
score += 100
elif hint in name or name in hint:
score += 40
for token in [t for t in hint.replace("-", " ").split() if len(t) >= 4]:
if token in name:
score += 5
ranked.append((score, row))
ranked.sort(key=lambda pair: (-pair[0], pair[1].name))
return ranked[0][1] if ranked else None
def _name_match(rows, text, allowed_roles=None):
target = _s(text).casefold()
if not target:
return None
ranked = []
for row in rows:
role = effective_role(row)
if allowed_roles and role not in set(allowed_roles):
continue
name = row.name.casefold()
score = 0
if target == name:
score = 300
elif target in name or name in target:
score = 180
else:
tokens = [t for t in target.replace("-", " ").replace(".", " ").split() if len(t) >= 4]
score = sum(12 for token in tokens if token in name)
if score:
ranked.append((score, row))
ranked.sort(key=lambda pair: (-pair[0], pair[1].name))
return ranked[0][1] if ranked else None
def _append_line(draft, line_no, ledger, side, amount, note=""):
if amount <= 0 or ledger is None:
return line_no
draft.lines.append(
AccountingVoucherLine(
line_no=line_no,
ledger_name=ledger.name,
ledger_role_code=effective_role(ledger),
dr_cr=side,
amount=round(float(amount), 2),
line_note=note,
)
)
return line_no + 1
def _audit_area(voucher_type, roles):
areas = []
if roles & {"SALES", "DIRECT_INCOME", "OTHER_INCOME"}:
areas.append("Revenue & Other Income")
if roles & {"PURCHASE", "DIRECT_EXPENSE", "INDIRECT_EXPENSE", "SUPPLIER"}:
areas.append("Purchases & Expenses")
if roles & {"BANK", "CASH"}:
areas.append("Cash & Bank")
if roles & {"GST_INPUT", "GST_OUTPUT", "TAX_OTHER"}:
areas.append("GST & Taxes")
if roles & {"CUSTOMER"}:
areas.append("Trade Receivables")
if not areas:
areas.append("General Ledger")
return " · ".join(dict.fromkeys(areas))
def create_bank_draft(
db,
*,
tenant_id: int,
client_id: int,
tally_guid: str,
company_name: str,
source_row: dict,
bank_ledger_name: str,
user_id: int,
):
rows = _ledger_candidates(
db,
tenant_id=tenant_id,
client_id=client_id,
tally_guid=tally_guid,
)
bank = _name_match(rows, bank_ledger_name, {"BANK", "CASH"})
if bank is None:
raise ValueError(
f"Bank/Cash ledger '{bank_ledger_name}' was not found in the client's Chart of Accounts."
)
direction = _s(source_row.get("direction")).upper()
amount = round(abs(float(source_row.get("amount") or 0)), 2)
party_hint = source_row.get("party") or source_row.get("narration") or ""
category = _s(source_row.get("category")).lower()
narration = _s(source_row.get("narration"))
contra_hint = _s(source_row.get("contra_hint"))
counter = None
voucher_type = ""
if contra_hint:
counter = _name_match(rows, party_hint, {"BANK", "CASH"})
if counter:
voucher_type = "Contra"
if not counter:
if direction == "DEBIT":
voucher_type = "Payment"
role_order = (
{"SUPPLIER"},
{"DIRECT_EXPENSE", "INDIRECT_EXPENSE"},
{"FIXED_ASSET", "CURRENT_ASSET"},
{"LOAN"},
{"CAPITAL"},
)
else:
voucher_type = "Receipt"
role_order = (
{"CUSTOMER"},
{"DIRECT_INCOME", "OTHER_INCOME", "SALES"},
{"LOAN"},
{"CAPITAL"},
)
for roles in role_order:
counter = _name_match(rows, party_hint, roles)
if counter:
break
if not counter and category:
if direction == "DEBIT":
counter = _name_match(rows, category, {"DIRECT_EXPENSE", "INDIRECT_EXPENSE", "PURCHASE"})
else:
counter = _name_match(rows, category, {"DIRECT_INCOME", "OTHER_INCOME", "SALES"})
if not counter:
raise ValueError(
"Counter-ledger could not be resolved confidently from the bank transaction. "
"Review the Chart of Accounts / historical learning before auto-creating this voucher."
)
draft = AccountingVoucherDraft(
tenant_id=int(tenant_id),
client_id=int(client_id),
tally_guid=tally_guid,
company_name=company_name,
voucher_type=voucher_type,
business_nature=voucher_type.upper(),
voucher_date=source_row.get("date") or datetime.now().date().isoformat(),
reference=source_row.get("reference") or "",
narration=narration,
source_type="bank",
source_record_type=source_row.get("source_table") or "bank",
source_record_id=source_row.get("source_id") or "",
source_evidence_json=json.dumps(source_row, ensure_ascii=False, default=str),
total_debit=amount,
total_credit=amount,
status="draft",
created_by_user_id=int(user_id),
updated_at_utc=_utcnow(),
)
if voucher_type == "Contra":
if direction == "DEBIT":
_append_line(draft, 1, counter, "DR", amount, "Matched own-bank transfer")
_append_line(draft, 2, bank, "CR", amount, narration)
else:
_append_line(draft, 1, bank, "DR", amount, narration)
_append_line(draft, 2, counter, "CR", amount, "Matched own-bank transfer")
elif voucher_type == "Payment":
_append_line(draft, 1, counter, "DR", amount, narration)
_append_line(draft, 2, bank, "CR", amount, narration)
else:
_append_line(draft, 1, bank, "DR", amount, narration)
_append_line(draft, 2, counter, "CR", amount, narration)
roles = {line.ledger_role_code for line in draft.lines}
draft.audit_area = _audit_area(voucher_type, roles)
db.add(draft)
db.commit()
db.refresh(draft)
return draft
def create_eway_invoice_draft(
db,
*,
tenant_id: int,
client_id: int,
tally_guid: str,
company_name: str,
invoice: dict,
user_id: int,
):
rows = _ledger_candidates(
db,
tenant_id=tenant_id,
client_id=client_id,
tally_guid=tally_guid,
)
direction = _s(invoice.get("direction")).upper()
if direction not in {"PURCHASE", "SALES"}:
raise ValueError("E-Way Bill direction could not be identified as Purchase or Sales.")
party_name = invoice.get("party_name") or ""
if direction == "PURCHASE":
party = _name_match(rows, party_name, {"SUPPLIER"})
main = _match_by_role(rows, {"PURCHASE"})
input_cgst = _name_match(rows, "input cgst", {"GST_INPUT"})
input_sgst = _name_match(rows, "input sgst", {"GST_INPUT"})
input_igst = _name_match(rows, "input igst", {"GST_INPUT"})
cess_ledger = _name_match(rows, "input cess", {"GST_INPUT", "TAX_OTHER"})
voucher_type = "Purchase"
item_side = "DR"
party_side = "CR"
else:
party = _name_match(rows, party_name, {"CUSTOMER"})
main = _match_by_role(rows, {"SALES"})
input_cgst = _name_match(rows, "output cgst", {"GST_OUTPUT"})
input_sgst = _name_match(rows, "output sgst", {"GST_OUTPUT"})
input_igst = _name_match(rows, "output igst", {"GST_OUTPUT"})
cess_ledger = _name_match(rows, "output cess", {"GST_OUTPUT", "TAX_OTHER"})
voucher_type = "Sales"
item_side = "CR"
party_side = "DR"
if party is None:
raise ValueError(
f"{'Supplier' if direction == 'PURCHASE' else 'Customer'} ledger for "
f"'{party_name}' could not be resolved confidently."
)
if main is None:
raise ValueError(
f"No {'Purchase' if direction == 'PURCHASE' else 'Sales'} ledger is classified "
"in the client's Chart of Accounts."
)
taxable = round(float(invoice.get("taxable_value") or 0), 2)
cgst = round(float(invoice.get("cgst") or 0), 2)
sgst = round(float(invoice.get("sgst") or 0), 2)
igst = round(float(invoice.get("igst") or 0), 2)
cess = round(float(invoice.get("cess") or 0), 2)
gross = round(taxable + cgst + sgst + igst + cess, 2)
draft = AccountingVoucherDraft(
tenant_id=int(tenant_id),
client_id=int(client_id),
tally_guid=tally_guid,
company_name=company_name,
voucher_type=voucher_type,
business_nature=direction,
voucher_date=invoice.get("invoice_date") or datetime.now().date().isoformat(),
reference=invoice.get("invoice_no") or invoice.get("eway_bill_no") or "",
narration=(
f"E-Way Bill {invoice.get('eway_bill_no') or '-'}"
f" · {party_name}"
),
source_type="ewaybill",
source_record_type=invoice.get("source_table") or "ewaybill",
source_record_id=",".join([x for x in invoice.get("source_ids", []) if x])[:120],
source_evidence_json=json.dumps(invoice, ensure_ascii=False, default=str),
total_debit=gross,
total_credit=gross,
status="draft",
created_by_user_id=int(user_id),
updated_at_utc=_utcnow(),
)
line_no = 1
# Accounting lines remain required for semantic/audit classification.
if direction == "PURCHASE":
line_no = _append_line(draft, line_no, main, "DR", taxable, "Stock item taxable value")
line_no = _append_line(draft, line_no, input_cgst, "DR", cgst, "Input CGST")
line_no = _append_line(draft, line_no, input_sgst, "DR", sgst, "Input SGST")
line_no = _append_line(draft, line_no, input_igst, "DR", igst, "Input IGST")
line_no = _append_line(draft, line_no, cess_ledger, "DR", cess, "Input Cess")
_append_line(draft, line_no, party, "CR", gross, party_name)
else:
line_no = _append_line(draft, line_no, party, "DR", gross, party_name)
line_no = _append_line(draft, line_no, main, "CR", taxable, "Stock item taxable value")
line_no = _append_line(draft, line_no, input_cgst, "CR", cgst, "Output CGST")
line_no = _append_line(draft, line_no, input_sgst, "CR", sgst, "Output SGST")
line_no = _append_line(draft, line_no, input_igst, "CR", igst, "Output IGST")
_append_line(draft, line_no, cess_ledger, "CR", cess, "Output Cess")
for index, item in enumerate(invoice.get("items") or [], 1):
item_amount = round(float(item.get("taxable_value") or 0), 2)
qty = float(item.get("quantity") or 0)
rate = float(item.get("rate") or 0)
if not rate and qty:
rate = item_amount / qty
draft.items.append(
AccountingVoucherItemLine(
line_no=index,
stock_item_name=_s(item.get("item_name")),
hsn_sac=_s(item.get("hsn")),
quantity=qty,
unit=_s(item.get("unit")),
rate=round(rate, 4),
amount=item_amount,
dr_cr=item_side,
accounting_ledger_name=main.name,
godown_name="",
source_item_json=json.dumps(item, ensure_ascii=False, default=str),
)
)
roles = {line.ledger_role_code for line in draft.lines}
draft.audit_area = _audit_area(voucher_type, roles)
draft.audit_flags_json = json.dumps(
["stock-item-wise", "eway-bill-source", "external-evidence"],
separators=(",", ":"),
)
db.add(draft)
db.commit()
db.refresh(draft)
return draft