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.stock_service import resolve_stock_item, resolve_unit 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) # Phase 21 resolves every source item against the synchronized ERP mirror # before a Purchase/Sales draft is created. Loose fuzzy-only candidates do # not silently pass through to Tally. resolved_items = [] unresolved = [] for source_item in invoice.get("items") or []: source_description = _s(source_item.get("item_name")) source_hsn = _s(source_item.get("hsn")) stock = resolve_stock_item( db, tenant_id=tenant_id, client_id=client_id, tally_guid=tally_guid, source_type="ewaybill", party_key=party_name, source_description=source_description, hsn_sac=source_hsn, ) unit = resolve_unit( db, tenant_id=tenant_id, client_id=client_id, tally_guid=tally_guid, source_unit=_s(source_item.get("unit")), ) if not stock.get("matched"): unresolved.append( f"{source_description or '(blank item)'}: {stock.get('reason') or 'stock item not mapped'}" ) continue if not unit.get("matched"): unresolved.append( f"{source_description or '(blank item)'}: {unit.get('reason') or 'unit not mapped'}" ) continue resolved_items.append( { "source": source_item, "stock": stock, "unit": unit, } ) if unresolved: raise ValueError( "Stock Item Intelligence review is required before this item-wise " f"{voucher_type} draft can be created. " + " | ".join(unresolved[:8]) ) 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, resolved in enumerate(resolved_items, 1): item = resolved["source"] mapped_stock = resolved["stock"]["stock_item"] unit_result = resolved["unit"] item_amount = round(float(item.get("taxable_value") or 0), 2) source_qty = float(item.get("quantity") or 0) source_rate = float(item.get("rate") or 0) factor = float(unit_result.get("quantity_factor") or 1.0) qty = source_qty * factor rate = source_rate / factor if source_rate and factor else source_rate if not rate and qty: rate = item_amount / qty evidence = { **item, "source_type": "ewaybill", "party_name": party_name, "source_stock_item_name": _s(item.get("item_name")), "mapped_stock_item_id": mapped_stock.id, "mapped_stock_item_name": mapped_stock.name, "stock_mapping_confidence": int(resolved["stock"].get("confidence") or 0), "stock_mapping_reason": resolved["stock"].get("reason") or "", "source_unit": _s(item.get("unit")), "mapped_unit": unit_result.get("target_unit") or "", "quantity_factor": factor, } draft.items.append( AccountingVoucherItemLine( line_no=index, stock_item_name=mapped_stock.name, hsn_sac=_s(item.get("hsn") or mapped_stock.hsn_code), quantity=qty, unit=unit_result.get("target_unit") or mapped_stock.base_units, rate=round(rate, 4), amount=item_amount, dr_cr=item_side, accounting_ledger_name=main.name, godown_name="", source_item_json=json.dumps(evidence, 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