377 lines
13 KiB
Python
377 lines
13 KiB
Python
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
|