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