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,56 @@
"""Phase 18 Automatic Source Driven Stock Item Voucher Engine.
Revision ID: 20260823_auto_stock_voucher_p18
Revises: 20260823_native_voucher_engine_p17
"""
from alembic import op
import sqlalchemy as sa
revision = "20260823_auto_stock_voucher_p18"
down_revision = "20260823_native_voucher_engine_p17"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"accounting_voucher_item_lines",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"draft_id",
sa.Integer(),
sa.ForeignKey("accounting_voucher_drafts.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("line_no", sa.Integer(), nullable=False, server_default="1"),
sa.Column("stock_item_name", sa.String(255), nullable=False),
sa.Column("hsn_sac", sa.String(30), nullable=False, server_default=""),
sa.Column("quantity", sa.Float(), nullable=False, server_default="0"),
sa.Column("unit", sa.String(40), nullable=False, server_default=""),
sa.Column("rate", sa.Float(), nullable=False, server_default="0"),
sa.Column("amount", sa.Float(), nullable=False, server_default="0"),
sa.Column("dr_cr", sa.String(2), nullable=False),
sa.Column("accounting_ledger_name", sa.String(255), nullable=False, server_default=""),
sa.Column("godown_name", sa.String(255), nullable=False, server_default=""),
sa.Column("source_item_json", sa.Text(), nullable=False, server_default="{}"),
)
op.create_index(
"ix_accounting_voucher_item_lines_draft_id",
"accounting_voucher_item_lines",
["draft_id"],
)
op.create_index(
"ix_accounting_voucher_item_lines_stock_item_name",
"accounting_voucher_item_lines",
["stock_item_name"],
)
op.create_index(
"ix_accounting_voucher_item_lines_hsn_sac",
"accounting_voucher_item_lines",
["hsn_sac"],
)
def downgrade():
op.drop_table("accounting_voucher_item_lines")
@@ -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
@@ -0,0 +1,351 @@
from __future__ import annotations
from urllib.parse import urlencode
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.templating import templates
from app.modules.accounting.automatic_voucher_service import (
create_bank_draft,
create_eway_invoice_draft,
)
from app.modules.accounting.chart_models import AccountingChartLedger
from app.modules.accounting.chart_service import effective_role
from app.modules.accounting.source_adapter_service import (
bank_rows,
discover_sources,
eway_rows,
group_eway_invoices,
)
from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
router = APIRouter(
prefix="/tools/accounting/voucher-engine/automatic",
tags=["accounting-automatic-voucher-source-ui"],
)
def _go(client_id=0, message="", error="", **extra):
params = {}
if client_id:
params["client_id"] = client_id
if message:
params["message"] = message[:350]
if error:
params["error"] = error[:350]
for key, value in extra.items():
if value not in ("", None):
params[key] = value
return RedirectResponse(
"/tools/accounting/voucher-engine/automatic"
+ ("?" + urlencode(params) if params else ""),
status_code=303,
)
def _company_options(db, tenant_id, client_id):
rows = list(
db.execute(
select(AccountingChartLedger.tally_guid, AccountingChartLedger.company_name)
.where(
AccountingChartLedger.tenant_id == int(tenant_id),
AccountingChartLedger.client_id == int(client_id),
)
.distinct()
.order_by(AccountingChartLedger.company_name)
).all()
)
return [{"guid": guid, "name": name or guid} for guid, name in rows if guid]
def _company_name(options, tally_guid):
return next((row["name"] for row in options if row["guid"] == tally_guid), "")
def _source_table(valid, requested):
names = {row.table_name for row in valid}
if requested in names:
return requested
return valid[0].table_name if valid else ""
@router.get("")
def page(
request: Request,
client_id: int | None = None,
tally_guid: str = "",
bank_source: str = "",
eway_source: str = "",
direction: str = "",
from_date: str = "",
to_date: str = "",
message: str = "",
error: str = "",
):
db = CommonSessionLocal()
try:
user, denied = _require_partner(request, db, "accounting.learning.view")
if denied:
return denied
clients, scope = _visible_clients(db, request, user)
selected = next(
(c for c in clients if client_id and int(c.id) == int(client_id)),
None,
)
company_options = []
sources = {"bank": [], "eway": []}
selected_bank_source = ""
selected_eway_source = ""
banks = []
eway_invoices = []
bank_ledgers = []
if selected:
company_options = _company_options(db, scope.tenant_id, selected.id)
if not tally_guid and len(company_options) == 1:
tally_guid = company_options[0]["guid"]
if tally_guid not in {row["guid"] for row in company_options}:
tally_guid = ""
if tally_guid:
bank_ledgers = list(
db.execute(
select(AccountingChartLedger)
.where(
AccountingChartLedger.tenant_id == int(scope.tenant_id),
AccountingChartLedger.client_id == int(selected.id),
AccountingChartLedger.tally_guid == tally_guid,
)
.order_by(AccountingChartLedger.name)
).scalars().all()
)
bank_ledgers = [
row for row in bank_ledgers
if effective_role(row) in {"BANK", "CASH"}
]
sources = discover_sources(db)
selected_bank_source = _source_table(sources["bank"], bank_source)
selected_eway_source = _source_table(sources["eway"], eway_source)
if selected_bank_source:
banks = bank_rows(
db,
table_name=selected_bank_source,
client_id=selected.id,
from_date=from_date,
to_date=to_date,
limit=300,
)
if selected_eway_source:
items = eway_rows(
db,
table_name=selected_eway_source,
client_id=selected.id,
direction=direction,
from_date=from_date,
to_date=to_date,
limit=5000,
)
eway_invoices = group_eway_invoices(items)
return templates.TemplateResponse(
"modules/accounting/templates/accounting/automatic_voucher_sources.html",
{
"request": request,
"current_user": user,
"current_user_roles": get_user_roles(db, user.id),
"current_user_permissions": get_user_permissions(db, user.id),
"csrf_token": get_or_create_csrf_token(request),
"title": "Automatic Accounting Preparation",
"clients": clients,
"selected_client": selected,
"selected_tally_guid": tally_guid,
"company_options": company_options,
"sources": sources,
"selected_bank_source": selected_bank_source,
"selected_eway_source": selected_eway_source,
"bank_rows": banks,
"eway_invoices": eway_invoices,
"bank_ledgers": bank_ledgers,
"direction": direction,
"from_date": from_date,
"to_date": to_date,
"message": message,
"error": error,
},
)
finally:
db.close()
@router.post("/bank/draft")
def bank_draft(
request: Request,
client_id: int = Form(...),
tally_guid: str = Form(...),
source_table: str = Form(...),
source_id: str = Form(...),
bank_ledger_name: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, denied = _require_partner(request, db, "accounting.learning.manage")
if denied:
return denied
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
raise ValueError("Client is not visible.")
sources = discover_sources(db)["bank"]
if source_table not in {row.table_name for row in sources}:
raise ValueError("Selected bank source is not an approved discovered source.")
source = next(
(
row
for row in bank_rows(
db,
table_name=source_table,
client_id=client.id,
limit=2000,
)
if str(row.get("source_id") or "") == str(source_id)
),
None,
)
if source is None:
raise ValueError("Bank transaction could not be reloaded from the source.")
options = _company_options(db, scope.tenant_id, client.id)
company_name = _company_name(options, tally_guid)
if not company_name:
raise ValueError("Selected Tally company is not available in Chart of Accounts.")
draft = create_bank_draft(
db,
tenant_id=scope.tenant_id,
client_id=client.id,
tally_guid=tally_guid,
company_name=company_name,
source_row=source,
bank_ledger_name=bank_ledger_name,
user_id=user.id,
)
return _go(
client.id,
tally_guid=tally_guid,
bank_source=source_table,
message=f"{draft.voucher_type} draft #{draft.id} created automatically from bank transaction.",
)
except Exception as exc:
db.rollback()
return _go(
client_id,
tally_guid=tally_guid,
bank_source=source_table,
error=str(exc),
)
finally:
db.close()
@router.post("/eway/draft")
def eway_draft(
request: Request,
client_id: int = Form(...),
tally_guid: str = Form(...),
source_table: str = Form(...),
direction: str = Form(...),
eway_bill_no: str = Form(""),
invoice_no: str = Form(""),
invoice_date: str = Form(""),
party_gstin: str = Form(""),
party_name: str = Form(""),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, denied = _require_partner(request, db, "accounting.learning.manage")
if denied:
return denied
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
raise ValueError("Client is not visible.")
sources = discover_sources(db)["eway"]
if source_table not in {row.table_name for row in sources}:
raise ValueError("Selected E-Way Bill source is not an approved discovered source.")
invoices = group_eway_invoices(
eway_rows(
db,
table_name=source_table,
client_id=client.id,
direction=direction,
limit=10000,
)
)
invoice = next(
(
row
for row in invoices
if (row.get("eway_bill_no") or "") == eway_bill_no
and (row.get("invoice_no") or "") == invoice_no
and (row.get("invoice_date") or "") == invoice_date
and (row.get("party_gstin") or "") == party_gstin
and (row.get("party_name") or "") == party_name
),
None,
)
if invoice is None:
raise ValueError("E-Way Bill invoice/items could not be reloaded from the source.")
options = _company_options(db, scope.tenant_id, client.id)
company_name = _company_name(options, tally_guid)
if not company_name:
raise ValueError("Selected Tally company is not available in Chart of Accounts.")
draft = create_eway_invoice_draft(
db,
tenant_id=scope.tenant_id,
client_id=client.id,
tally_guid=tally_guid,
company_name=company_name,
invoice=invoice,
user_id=user.id,
)
return _go(
client.id,
tally_guid=tally_guid,
eway_source=source_table,
direction=direction,
message=(
f"{draft.voucher_type} draft #{draft.id} created automatically "
f"with {len(draft.items)} stock-item line(s)."
),
)
except Exception as exc:
db.rollback()
return _go(
client_id,
tally_guid=tally_guid,
eway_source=source_table,
direction=direction,
error=str(exc),
)
finally:
db.close()
@@ -0,0 +1,370 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import MetaData, Table, and_, inspect, select
@dataclass(frozen=True)
class SourceDescriptor:
kind: str
table_name: str
score: int
columns: tuple[str, ...]
BANK_TABLE_HINTS = (
"bank_statement_transaction",
"bank_transactions",
"bank_analyzer_transaction",
"bank_statement_rows",
"bank_entries",
)
EWAY_TABLE_HINTS = (
"ewaybill",
"eway_bill",
"e_way_bill",
"accounting_purchase_source",
"purchase_source",
"sales_source",
)
BANK_COLUMNS = {
"id": ("id", "transaction_id", "row_id"),
"client_id": ("client_id",),
"date": ("transaction_date", "txn_date", "date", "value_date"),
"narration": ("narration", "description", "particulars", "remarks"),
"debit": ("debit", "withdrawal", "debit_amount"),
"credit": ("credit", "deposit", "credit_amount"),
"amount": ("amount",),
"dr_cr": ("dr_cr", "type", "nature"),
"party": ("final_party", "manual_party", "auto_party", "party", "counterparty"),
"category": ("category", "final_category", "nature"),
"account_no": ("account_number", "bank_account_number", "account_no"),
"bank": ("bank_name", "bank"),
"reference": ("reference", "ref_no", "utr", "transaction_reference"),
"contra": ("contra_match_id", "contra_pair_id", "is_contra"),
}
EWAY_COLUMNS = {
"id": ("id", "item_id", "row_id"),
"client_id": ("client_id",),
"direction": ("direction", "supply_type", "transaction_type", "inward_outward"),
"ewb_no": ("eway_bill_no", "ewb_no", "ewaybill_no"),
"invoice_no": ("invoice_no", "doc_no", "document_number", "bill_no"),
"invoice_date": ("invoice_date", "doc_date", "document_date", "date"),
"party_name": ("supplier_name", "customer_name", "party_name", "trade_name", "legal_name"),
"party_gstin": ("supplier_gstin", "customer_gstin", "party_gstin", "gstin"),
"item_name": ("product_name", "item_name", "description", "product_description"),
"hsn": ("hsn", "hsn_code", "hsn_sac"),
"quantity": ("quantity", "qty"),
"unit": ("unit", "uqc"),
"rate": ("rate", "unit_rate"),
"taxable_value": ("taxable_value", "taxable_amount", "assessable_value", "amount"),
"cgst": ("cgst", "cgst_amount"),
"sgst": ("sgst", "sgst_amount"),
"igst": ("igst", "igst_amount"),
"cess": ("cess", "cess_amount"),
"tax_rate": ("tax_rate", "gst_rate", "rate_percent"),
"vehicle_no": ("vehicle_no", "vehicle_number"),
}
def _norm(value: Any) -> str:
return str(value or "").strip()
def _float(value: Any) -> float:
if value in (None, ""):
return 0.0
if isinstance(value, Decimal):
return float(value)
try:
return float(str(value).replace(",", ""))
except Exception:
return 0.0
def _filter_date(column, value: str):
text = _norm(value)
if not text:
return None
type_name = column.type.__class__.__name__.lower()
if "date" in type_name or "time" in type_name:
try:
return date.fromisoformat(text)
except Exception:
return text
return text
def _date_text(value: Any) -> str:
if value in (None, ""):
return ""
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
text = _norm(value)
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%Y%m%d"):
try:
return datetime.strptime(text, fmt).date().isoformat()
except Exception:
pass
return text[:10]
def _resolve(columns: set[str], choices: tuple[str, ...]) -> str:
lower = {c.lower(): c for c in columns}
for candidate in choices:
if candidate.lower() in lower:
return lower[candidate.lower()]
return ""
def _table_score(name: str, cols: set[str], hints: tuple[str, ...], mapping: dict) -> int:
score = 0
lname = name.lower()
if any(h in lname for h in hints):
score += 12
for choices in mapping.values():
if _resolve(cols, choices):
score += 1
return score
def discover_sources(db) -> dict[str, list[SourceDescriptor]]:
inspector = inspect(db.get_bind())
result = {"bank": [], "eway": []}
for table_name in inspector.get_table_names():
try:
cols = {row["name"] for row in inspector.get_columns(table_name)}
except Exception:
continue
bank_score = _table_score(table_name, cols, BANK_TABLE_HINTS, BANK_COLUMNS)
if bank_score >= 18 and (
_resolve(cols, BANK_COLUMNS["narration"])
and (
_resolve(cols, BANK_COLUMNS["amount"])
or _resolve(cols, BANK_COLUMNS["debit"])
or _resolve(cols, BANK_COLUMNS["credit"])
)
):
result["bank"].append(
SourceDescriptor("bank", table_name, bank_score, tuple(sorted(cols)))
)
eway_score = _table_score(table_name, cols, EWAY_TABLE_HINTS, EWAY_COLUMNS)
if eway_score >= 17 and (
_resolve(cols, EWAY_COLUMNS["ewb_no"])
and _resolve(cols, EWAY_COLUMNS["item_name"])
and _resolve(cols, EWAY_COLUMNS["taxable_value"])
):
result["eway"].append(
SourceDescriptor("eway", table_name, eway_score, tuple(sorted(cols)))
)
for key in result:
result[key] = sorted(result[key], key=lambda row: (-row.score, row.table_name))
return result
def _table(db, table_name: str):
metadata = MetaData()
return Table(table_name, metadata, autoload_with=db.get_bind())
def bank_rows(
db,
*,
table_name: str,
client_id: int,
from_date: str = "",
to_date: str = "",
limit: int = 500,
):
table = _table(db, table_name)
cols = set(table.c.keys())
cmap = {key: _resolve(cols, choices) for key, choices in BANK_COLUMNS.items()}
conditions = []
if cmap["client_id"]:
conditions.append(table.c[cmap["client_id"]] == int(client_id))
if from_date and cmap["date"]:
conditions.append(table.c[cmap["date"]] >= _filter_date(table.c[cmap["date"]], from_date))
if to_date and cmap["date"]:
conditions.append(table.c[cmap["date"]] <= _filter_date(table.c[cmap["date"]], to_date))
stmt = select(table)
if conditions:
stmt = stmt.where(and_(*conditions))
if cmap["date"]:
stmt = stmt.order_by(table.c[cmap["date"]].desc())
stmt = stmt.limit(max(1, min(2000, int(limit))))
rows = []
for raw in db.execute(stmt).mappings():
debit = _float(raw.get(cmap["debit"])) if cmap["debit"] else 0.0
credit = _float(raw.get(cmap["credit"])) if cmap["credit"] else 0.0
amount = _float(raw.get(cmap["amount"])) if cmap["amount"] else 0.0
dr_cr = _norm(raw.get(cmap["dr_cr"])).upper() if cmap["dr_cr"] else ""
if debit > 0:
direction = "DEBIT"
value = debit
elif credit > 0:
direction = "CREDIT"
value = credit
elif dr_cr.startswith("D"):
direction = "DEBIT"
value = abs(amount)
elif dr_cr.startswith("C"):
direction = "CREDIT"
value = abs(amount)
elif amount < 0:
direction = "DEBIT"
value = abs(amount)
else:
direction = "CREDIT"
value = abs(amount)
rows.append(
{
"source_table": table_name,
"source_id": _norm(raw.get(cmap["id"])) if cmap["id"] else "",
"date": _date_text(raw.get(cmap["date"])) if cmap["date"] else "",
"narration": _norm(raw.get(cmap["narration"])) if cmap["narration"] else "",
"party": _norm(raw.get(cmap["party"])) if cmap["party"] else "",
"category": _norm(raw.get(cmap["category"])) if cmap["category"] else "",
"bank_name": _norm(raw.get(cmap["bank"])) if cmap["bank"] else "",
"account_number": _norm(raw.get(cmap["account_no"])) if cmap["account_no"] else "",
"reference": _norm(raw.get(cmap["reference"])) if cmap["reference"] else "",
"direction": direction,
"amount": round(value, 2),
"contra_hint": _norm(raw.get(cmap["contra"])) if cmap["contra"] else "",
}
)
return rows
def eway_rows(
db,
*,
table_name: str,
client_id: int,
direction: str = "",
from_date: str = "",
to_date: str = "",
limit: int = 2000,
):
table = _table(db, table_name)
cols = set(table.c.keys())
cmap = {key: _resolve(cols, choices) for key, choices in EWAY_COLUMNS.items()}
conditions = []
if cmap["client_id"]:
conditions.append(table.c[cmap["client_id"]] == int(client_id))
if from_date and cmap["invoice_date"]:
conditions.append(table.c[cmap["invoice_date"]] >= _filter_date(table.c[cmap["invoice_date"]], from_date))
if to_date and cmap["invoice_date"]:
conditions.append(table.c[cmap["invoice_date"]] <= _filter_date(table.c[cmap["invoice_date"]], to_date))
stmt = select(table)
if conditions:
stmt = stmt.where(and_(*conditions))
if cmap["invoice_date"]:
stmt = stmt.order_by(table.c[cmap["invoice_date"]].desc())
stmt = stmt.limit(max(1, min(10000, int(limit))))
normalized_direction = _norm(direction).upper()
rows = []
for raw in db.execute(stmt).mappings():
raw_direction = _norm(raw.get(cmap["direction"])).upper() if cmap["direction"] else ""
detected = ""
if raw_direction in {"I", "IN", "INWARD", "PURCHASE"} or "INWARD" in raw_direction or "PURCHASE" in raw_direction:
detected = "PURCHASE"
elif raw_direction in {"O", "OUT", "OUTWARD", "SALES"} or "OUTWARD" in raw_direction or "SALES" in raw_direction:
detected = "SALES"
if normalized_direction and detected and detected != normalized_direction:
continue
taxable = round(abs(_float(raw.get(cmap["taxable_value"]))), 2)
qty = _float(raw.get(cmap["quantity"])) if cmap["quantity"] else 0.0
rate = _float(raw.get(cmap["rate"])) if cmap["rate"] else 0.0
if not rate and qty:
rate = taxable / qty
rows.append(
{
"source_table": table_name,
"source_id": _norm(raw.get(cmap["id"])) if cmap["id"] else "",
"direction": detected or normalized_direction or "UNKNOWN",
"eway_bill_no": _norm(raw.get(cmap["ewb_no"])) if cmap["ewb_no"] else "",
"invoice_no": _norm(raw.get(cmap["invoice_no"])) if cmap["invoice_no"] else "",
"invoice_date": _date_text(raw.get(cmap["invoice_date"])) if cmap["invoice_date"] else "",
"party_name": _norm(raw.get(cmap["party_name"])) if cmap["party_name"] else "",
"party_gstin": _norm(raw.get(cmap["party_gstin"])) if cmap["party_gstin"] else "",
"item_name": _norm(raw.get(cmap["item_name"])) if cmap["item_name"] else "",
"hsn": _norm(raw.get(cmap["hsn"])) if cmap["hsn"] else "",
"quantity": qty,
"unit": _norm(raw.get(cmap["unit"])) if cmap["unit"] else "",
"rate": round(rate, 4),
"taxable_value": taxable,
"cgst": round(abs(_float(raw.get(cmap["cgst"]))), 2) if cmap["cgst"] else 0.0,
"sgst": round(abs(_float(raw.get(cmap["sgst"]))), 2) if cmap["sgst"] else 0.0,
"igst": round(abs(_float(raw.get(cmap["igst"]))), 2) if cmap["igst"] else 0.0,
"cess": round(abs(_float(raw.get(cmap["cess"]))), 2) if cmap["cess"] else 0.0,
"tax_rate": _float(raw.get(cmap["tax_rate"])) if cmap["tax_rate"] else 0.0,
"vehicle_no": _norm(raw.get(cmap["vehicle_no"])) if cmap["vehicle_no"] else "",
}
)
return rows
def group_eway_invoices(rows: list[dict]) -> list[dict]:
grouped: dict[tuple, dict] = {}
for row in rows:
key = (
row.get("direction") or "",
row.get("eway_bill_no") or "",
row.get("invoice_no") or "",
row.get("invoice_date") or "",
row.get("party_gstin") or "",
row.get("party_name") or "",
)
group = grouped.setdefault(
key,
{
"direction": row.get("direction") or "",
"eway_bill_no": row.get("eway_bill_no") or "",
"invoice_no": row.get("invoice_no") or "",
"invoice_date": row.get("invoice_date") or "",
"party_name": row.get("party_name") or "",
"party_gstin": row.get("party_gstin") or "",
"source_table": row.get("source_table") or "",
"source_ids": [],
"items": [],
"taxable_value": 0.0,
"cgst": 0.0,
"sgst": 0.0,
"igst": 0.0,
"cess": 0.0,
},
)
group["source_ids"].append(row.get("source_id") or "")
group["items"].append(row)
for field in ("taxable_value", "cgst", "sgst", "igst", "cess"):
group[field] = round(group[field] + float(row.get(field) or 0), 2)
return sorted(
grouped.values(),
key=lambda x: (x["invoice_date"], x["invoice_no"], x["eway_bill_no"]),
reverse=True,
)
@@ -0,0 +1,244 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting Engine · Phase 18</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Automatic Accounting Preparation</h1>
<p class="mt-1 max-w-5xl text-sm text-slate-500">
Create native Tally voucher drafts automatically from existing Bank Analyzer and E-Way Bill source data. Purchase and Sales drafts retain stock-item level quantity, unit, rate, HSN and taxable value for native Tally inventory vouchers.
</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/voucher-engine{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Voucher Review Queue</a>
<a href="/tools/accounting/chart-of-accounts{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Chart of Accounts</a>
</div>
</div>
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" class="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<label class="text-xs font-semibold xl:col-span-2">ERP Client
<select name="client_id" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select client</option>
{% for c in clients %}
<option value="{{ c.id }}" {% if selected_client and selected_client.id==c.id %}selected{% endif %}>{{ c.client_name }}</option>
{% endfor %}
</select>
</label>
<label class="text-xs font-semibold xl:col-span-2">Tally Company / Chart of Accounts
<select name="tally_guid" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select company</option>
{% for c in company_options %}
<option value="{{ c.guid }}" {% if selected_tally_guid==c.guid %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">From
<input type="date" name="from_date" value="{{ from_date }}" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
</label>
<label class="text-xs font-semibold">To
<input type="date" name="to_date" value="{{ to_date }}" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
</label>
<div class="xl:col-span-6 flex justify-end">
<button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white">Load Automatic Sources</button>
</div>
</form>
</section>
{% if selected_client and selected_tally_guid %}
<section class="grid gap-4 lg:grid-cols-3">
<div class="rounded-2xl border border-emerald-200 bg-emerald-50/50 p-5">
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-700">Bank Statements</p>
<h2 class="mt-1 font-semibold">Receipt / Payment / Contra</h2>
<p class="mt-1 text-sm text-slate-600">Bank Analyzer rows are converted into native Receipt, Payment or Contra voucher drafts. The bank ledger is chosen from the client's classified Chart of Accounts.</p>
</div>
<div class="rounded-2xl border border-blue-200 bg-blue-50/50 p-5">
<p class="text-xs font-semibold uppercase tracking-wider text-blue-700">E-Way Bill · Inward</p>
<h2 class="mt-1 font-semibold">Stock-item Purchase</h2>
<p class="mt-1 text-sm text-slate-600">Each invoice retains every item, HSN, quantity, unit, rate, taxable value and tax amounts and creates a native Purchase voucher draft.</p>
</div>
<div class="rounded-2xl border border-violet-200 bg-violet-50/50 p-5">
<p class="text-xs font-semibold uppercase tracking-wider text-violet-700">E-Way Bill · Outward</p>
<h2 class="mt-1 font-semibold">Stock-item Sales</h2>
<p class="mt-1 text-sm text-slate-600">Outward invoices create native Sales voucher drafts with item-wise inventory allocations and output-tax ledger lines.</p>
</div>
</section>
<section class="rounded-2xl bg-white shadow-soft">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 p-5">
<div>
<h2 class="font-semibold">Bank Analyzer Transactions</h2>
<p class="text-sm text-slate-500">One click creates the proposed Receipt / Payment / Contra draft. Posting remains subject to the common review and Tally preflight controls.</p>
</div>
<form method="get" class="flex flex-wrap gap-2">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
<input type="hidden" name="from_date" value="{{ from_date }}">
<input type="hidden" name="to_date" value="{{ to_date }}">
<select name="bank_source" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
{% for source in sources.bank %}
<option value="{{ source.table_name }}" {% if selected_bank_source==source.table_name %}selected{% endif %}>{{ source.table_name }}</option>
{% endfor %}
</select>
<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Change Source</button>
</form>
</div>
{% if not sources.bank %}
<div class="p-8 text-center text-slate-500">
No existing Bank Analyzer transaction table containing client/date/narration/amount fields was discovered in the current ERP schema. Existing Bank Analyzer data has not been changed.
</div>
{% elif not bank_rows %}
<div class="p-8 text-center text-slate-500">No Bank Analyzer transactions were found for the selected client/period.</div>
{% else %}
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Narration / Party</th>
<th class="px-4 py-3">Direction</th>
<th class="px-4 py-3 text-right">Amount</th>
<th class="px-4 py-3">Bank/Cash Ledger</th>
<th class="px-4 py-3">Action</th>
</tr>
</thead>
<tbody>
{% for row in bank_rows %}
<tr class="border-t border-slate-100 align-top">
<td class="px-4 py-3 whitespace-nowrap">{{ row.date or '-' }}</td>
<td class="px-4 py-3">
<div class="font-semibold">{{ row.party or row.narration or '-' }}</div>
{% if row.party and row.narration %}<div class="mt-1 max-w-xl text-xs text-slate-500">{{ row.narration }}</div>{% endif %}
{% if row.category %}<div class="mt-1 text-xs text-slate-500">Category: {{ row.category }}</div>{% endif %}
{% if row.reference %}<div class="text-xs text-slate-500">Ref: {{ row.reference }}</div>{% endif %}
</td>
<td class="px-4 py-3"><span class="rounded-full px-2 py-1 text-xs font-semibold {% if row.direction=='DEBIT' %}bg-amber-100 text-amber-800{% else %}bg-emerald-100 text-emerald-800{% endif %}">{{ row.direction }}</span></td>
<td class="px-4 py-3 text-right font-semibold">₹{{ '%.2f'|format(row.amount) }}</td>
<td class="px-4 py-3">
<form method="post" action="/tools/accounting/voucher-engine/automatic/bank/draft" class="flex min-w-[320px] gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
<input type="hidden" name="source_table" value="{{ row.source_table }}">
<input type="hidden" name="source_id" value="{{ row.source_id }}">
<select name="bank_ledger_name" required class="min-w-[190px] flex-1 rounded-lg border border-slate-300 bg-white px-2 py-2 text-xs">
<option value="">Select Bank/Cash ledger</option>
{% for ledger in bank_ledgers %}
<option value="{{ ledger.name }}">{{ ledger.name }}</option>
{% endfor %}
</select>
<button class="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-white">Create Draft</button>
</form>
</td>
<td class="px-4 py-3 text-xs text-slate-500">{% if row.contra_hint %}Contra hint detected{% else %}Auto ledger matching from party/category{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</section>
<section class="rounded-2xl bg-white shadow-soft">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 p-5">
<div>
<h2 class="font-semibold">E-Way Bill Purchase / Sales Invoices</h2>
<p class="text-sm text-slate-500">Invoices are grouped by E-Way Bill + invoice + party. Every underlying item remains separate in the voucher draft.</p>
</div>
<form method="get" class="flex flex-wrap gap-2">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
<input type="hidden" name="from_date" value="{{ from_date }}">
<input type="hidden" name="to_date" value="{{ to_date }}">
<select name="eway_source" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
{% for source in sources.eway %}
<option value="{{ source.table_name }}" {% if selected_eway_source==source.table_name %}selected{% endif %}>{{ source.table_name }}</option>
{% endfor %}
</select>
<select name="direction" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="" {% if not direction %}selected{% endif %}>Purchase + Sales</option>
<option value="PURCHASE" {% if direction=='PURCHASE' %}selected{% endif %}>Purchase / Inward</option>
<option value="SALES" {% if direction=='SALES' %}selected{% endif %}>Sales / Outward</option>
</select>
<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Apply</button>
</form>
</div>
{% if not sources.eway %}
<div class="p-8 text-center text-slate-500">
No existing E-Way Bill item table containing E-Way Bill number, item description and taxable value was discovered in the current ERP schema. Existing E-Way Bill features have not been modified.
</div>
{% elif not eway_invoices %}
<div class="p-8 text-center text-slate-500">No E-Way Bill item invoices were found for this client/period/filter.</div>
{% else %}
<div class="divide-y divide-slate-100">
{% for inv in eway_invoices %}
<article class="p-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<div class="flex flex-wrap gap-2">
<span class="rounded-full px-2 py-1 text-xs font-semibold {% if inv.direction=='PURCHASE' %}bg-blue-100 text-blue-800{% elif inv.direction=='SALES' %}bg-violet-100 text-violet-800{% else %}bg-slate-100{% endif %}">{{ inv.direction }}</span>
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ inv.items|length }} stock item(s)</span>
</div>
<h3 class="mt-2 font-semibold">{{ inv.party_name or 'Party not available' }}</h3>
<p class="text-sm text-slate-500">GSTIN {{ inv.party_gstin or '-' }} · Invoice {{ inv.invoice_no or '-' }} · {{ inv.invoice_date or '-' }} · EWB {{ inv.eway_bill_no or '-' }}</p>
</div>
<div class="text-right">
<div class="font-semibold">Taxable ₹{{ '%.2f'|format(inv.taxable_value) }}</div>
<div class="text-xs text-slate-500">CGST {{ '%.2f'|format(inv.cgst) }} · SGST {{ '%.2f'|format(inv.sgst) }} · IGST {{ '%.2f'|format(inv.igst) }} · Cess {{ '%.2f'|format(inv.cess) }}</div>
</div>
</div>
<div class="mt-4 overflow-x-auto rounded-xl border border-slate-200">
<table class="min-w-full text-xs">
<thead><tr class="bg-slate-50 text-left uppercase text-slate-500"><th class="px-3 py-2">Stock Item / Description</th><th class="px-3 py-2">HSN</th><th class="px-3 py-2 text-right">Qty</th><th class="px-3 py-2">Unit</th><th class="px-3 py-2 text-right">Rate</th><th class="px-3 py-2 text-right">Taxable</th></tr></thead>
<tbody>
{% for item in inv.items %}
<tr class="border-t border-slate-100">
<td class="px-3 py-2 font-semibold">{{ item.item_name or '-' }}</td>
<td class="px-3 py-2">{{ item.hsn or '-' }}</td>
<td class="px-3 py-2 text-right">{{ item.quantity }}</td>
<td class="px-3 py-2">{{ item.unit or '-' }}</td>
<td class="px-3 py-2 text-right">{{ '%.4f'|format(item.rate) }}</td>
<td class="px-3 py-2 text-right">₹{{ '%.2f'|format(item.taxable_value) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<form method="post" action="/tools/accounting/voucher-engine/automatic/eway/draft" class="mt-4 flex justify-end">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
<input type="hidden" name="source_table" value="{{ inv.source_table }}">
<input type="hidden" name="direction" value="{{ inv.direction }}">
<input type="hidden" name="eway_bill_no" value="{{ inv.eway_bill_no }}">
<input type="hidden" name="invoice_no" value="{{ inv.invoice_no }}">
<input type="hidden" name="invoice_date" value="{{ inv.invoice_date }}">
<input type="hidden" name="party_gstin" value="{{ inv.party_gstin }}">
<input type="hidden" name="party_name" value="{{ inv.party_name }}">
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Create Stock-item {{ inv.direction|title }} Draft</button>
</form>
</article>
{% endfor %}
</div>
{% endif %}
</section>
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-5">
<h2 class="font-semibold">Manual entry remains only as a fallback</h2>
<p class="mt-1 text-sm text-slate-600">Manual / adjustment voucher creation has not been removed. It remains useful for provisions, accruals, depreciation, reclassification, audit adjustments and source transactions that cannot yet be resolved automatically.</p>
<a href="/tools/accounting/voucher-engine?client_id={{ selected_client.id }}" class="mt-3 inline-flex rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Open Manual / Review Voucher Engine</a>
</section>
{% endif %}
</div>
{% endblock %}
@@ -10,6 +10,7 @@
</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/voucher-engine/automatic{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Automatic Preparation</a>
<a href="/tools/accounting/chart-of-accounts{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Chart of Accounts</a>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Back to Accounting</a>
</div>
@@ -57,8 +58,8 @@
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="mb-4">
<h2 class="font-semibold text-slate-900">Create Voucher Draft</h2>
<p class="text-sm text-slate-500">Every ledger must already exist in the Phase 16 Chart of Accounts. Debit and Credit must balance before the draft is accepted.</p>
<h2 class="font-semibold text-slate-900">Manual / Adjustment Voucher Draft</h2>
<p class="text-sm text-slate-500">Automatic Bank/E-Way Bill preparation is available above. Use this manual form for adjustments, provisions, accruals, depreciation, reclassification, audit adjustments or unresolved source transactions. Every ledger must exist in the Chart of Accounts and Debit must equal Credit.</p>
</div>
<form method="post" action="/tools/accounting/voucher-engine/draft" id="voucherDraftForm" class="space-y-5">
@@ -201,6 +202,28 @@
</table>
</div>
{% if draft.items %}
<div class="mt-3 overflow-x-auto rounded-xl border border-blue-200">
<div class="border-b border-blue-100 bg-blue-50 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-blue-800">Stock-item allocation</div>
<table class="min-w-full text-xs">
<thead><tr class="bg-slate-50 text-left uppercase text-slate-500"><th class="px-3 py-2">Stock Item</th><th class="px-3 py-2">HSN</th><th class="px-3 py-2 text-right">Qty</th><th class="px-3 py-2">Unit</th><th class="px-3 py-2 text-right">Rate</th><th class="px-3 py-2 text-right">Amount</th><th class="px-3 py-2">Accounting Ledger</th></tr></thead>
<tbody>
{% for item in draft.items %}
<tr class="border-t border-slate-100">
<td class="px-3 py-2 font-semibold">{{ item.stock_item_name }}</td>
<td class="px-3 py-2">{{ item.hsn_sac or '-' }}</td>
<td class="px-3 py-2 text-right">{{ item.quantity }}</td>
<td class="px-3 py-2">{{ item.unit or '-' }}</td>
<td class="px-3 py-2 text-right">{{ '%.4f'|format(item.rate) }}</td>
<td class="px-3 py-2 text-right">₹{{ '%.2f'|format(item.amount) }}</td>
<td class="px-3 py-2">{{ item.accounting_ledger_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if draft.last_error %}<div class="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ draft.last_error }}</div>{% endif %}
{% if draft.status == 'preflight_ready' %}<div class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">Tally preflight completed. Ledger existence, balancing and duplicate checks completed.</div>{% endif %}
@@ -65,6 +65,13 @@ class AccountingVoucherDraft(CommonBase):
order_by="AccountingVoucherLine.line_no",
)
items: Mapped[list["AccountingVoucherItemLine"]] = relationship(
"AccountingVoucherItemLine",
back_populates="draft",
cascade="all, delete-orphan",
order_by="AccountingVoucherItemLine.line_no",
)
class AccountingVoucherLine(CommonBase):
__tablename__ = "accounting_voucher_lines"
@@ -79,3 +86,30 @@ class AccountingVoucherLine(CommonBase):
line_note: Mapped[str] = mapped_column(Text, nullable=False, default="")
draft: Mapped[AccountingVoucherDraft] = relationship("AccountingVoucherDraft", back_populates="lines")
class AccountingVoucherItemLine(CommonBase):
__tablename__ = "accounting_voucher_item_lines"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
draft_id: Mapped[int] = mapped_column(
ForeignKey("accounting_voucher_drafts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
line_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
stock_item_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
hsn_sac: Mapped[str] = mapped_column(String(30), nullable=False, default="", index=True)
quantity: Mapped[float] = mapped_column(Float, nullable=False, default=0)
unit: Mapped[str] = mapped_column(String(40), nullable=False, default="")
rate: Mapped[float] = mapped_column(Float, nullable=False, default=0)
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
dr_cr: Mapped[str] = mapped_column(String(2), nullable=False)
accounting_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
godown_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
source_item_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
draft: Mapped[AccountingVoucherDraft] = relationship(
"AccountingVoucherDraft",
back_populates="items",
)
@@ -310,7 +310,7 @@ def create_draft(
def get_draft(db, *, tenant_id: int, client_id: int, draft_id: int):
return db.execute(
select(AccountingVoucherDraft)
.options(selectinload(AccountingVoucherDraft.lines))
.options(selectinload(AccountingVoucherDraft.lines), selectinload(AccountingVoucherDraft.items))
.where(
AccountingVoucherDraft.id == int(draft_id),
AccountingVoucherDraft.tenant_id == int(tenant_id),
@@ -335,7 +335,7 @@ def list_drafts(db, *, tenant_id: int, client_id: int, status: str = "", page: i
rows = list(
db.execute(
select(AccountingVoucherDraft)
.options(selectinload(AccountingVoucherDraft.lines))
.options(selectinload(AccountingVoucherDraft.lines), selectinload(AccountingVoucherDraft.items))
.where(*where)
.order_by(AccountingVoucherDraft.id.desc())
.offset((page - 1) * per_page)
@@ -440,6 +440,21 @@ def _payload(draft: AccountingVoucherDraft):
}
for line in draft.lines
],
"items": [
{
"stock_item_name": item.stock_item_name,
"hsn_sac": item.hsn_sac,
"quantity": float(item.quantity),
"unit": item.unit,
"rate": float(item.rate),
"amount": float(item.amount),
"dr_cr": item.dr_cr,
"accounting_ledger_name": item.accounting_ledger_name,
"godown_name": item.godown_name,
"source_item_json": item.source_item_json,
}
for item in draft.items
],
}
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path
import zipfile
ERP_LOCAL_AGENT_VERSION = "1.17.0"
ERP_LOCAL_AGENT_VERSION = "1.18.0"
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
@@ -1,2 +1,2 @@
__version__ = "1.17.0"
__version__ = "1.18.0"
AGENT_NAME = "ERP Local Agent"
@@ -83,7 +83,7 @@ class AgentCommandProcessor:
return {
"name": "ERP Local Agent", "version": __version__,
"tally_capability": True, "accounting_act_capability": True,
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True,
"tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True,
"tally_transaction_sync_capability": True,
"historical_learning_read_capability": True,
"purchase_posting_preflight_capability": True,
@@ -571,6 +571,7 @@ class AgentCommandProcessor:
reference=str(payload.get("reference") or ""),
total_amount=float(payload.get("total_amount") or 0),
lines=list(payload.get("lines") or []),
items=list(payload.get("items") or []),
)
return {
**result,
@@ -590,6 +591,7 @@ class AgentCommandProcessor:
reference=str(payload.get("reference") or ""),
total_amount=float(payload.get("total_amount") or 0),
lines=list(payload.get("lines") or []),
items=list(payload.get("items") or []),
)
if preflight.get("duplicate_candidates"):
raise ValueError("Possible duplicate native Tally voucher exists. Posting blocked.")
@@ -601,6 +603,7 @@ class AgentCommandProcessor:
reference=str(payload.get("reference") or ""),
narration=str(payload.get("narration") or ""),
lines=list(payload.get("lines") or []),
items=list(payload.get("items") or []),
)
safe_result = {
key: value
@@ -40,11 +40,10 @@ def _tally_date(value):
class NativeVoucherEngine:
"""Native Tally accounting-voucher writer layered over the existing connector.
"""Native Tally accounting-voucher writer.
It intentionally reuses the current Tally connector's XML transport,
ledger discovery and voucher export functions so no existing posting
behaviour is replaced.
Phase 18 preserves the Phase 17 ledger-voucher behaviour and adds true
stock-item inventory allocations for Purchase/Sales vouchers.
"""
def __init__(self, tally_connector):
@@ -78,9 +77,7 @@ class NativeVoucherEngine:
if amount <= 0:
raise ValueError(f"Voucher line {index + 1}: amount must be greater than zero.")
actual_name = str(
available[ledger_name.casefold()].get("name") or ledger_name
).strip()
actual_name = str(available[ledger_name.casefold()].get("name") or ledger_name).strip()
if side == "DR":
total_dr += amount
@@ -90,6 +87,7 @@ class NativeVoucherEngine:
normalized.append(
{
"ledger_name": actual_name,
"ledger_role_code": str(line.get("ledger_role_code") or "").strip(),
"dr_cr": side,
"amount": amount,
}
@@ -109,6 +107,131 @@ class NativeVoucherEngine:
return normalized, total_dr, total_cr
def _stock_item_names(self, company_name: str):
request_xml = f"""<ENVELOPE>
<HEADER>
<VERSION>1</VERSION>
<TALLYREQUEST>Export</TALLYREQUEST>
<TYPE>Collection</TYPE>
<ID>ERPStockItemCollection</ID>
</HEADER>
<BODY>
<DESC>
<STATICVARIABLES>
<SVCURRENTCOMPANY>{self.tally._xml_escape(company_name)}</SVCURRENTCOMPANY>
<SVEXPORTFORMAT>$$SysName:XML</SVEXPORTFORMAT>
</STATICVARIABLES>
<TDL>
<TDLMESSAGE>
<COLLECTION NAME="ERPStockItemCollection" ISMODIFY="No">
<TYPE>Stock Item</TYPE>
<FETCH>Name,BaseUnits,GSTApplicable,GSTTypeOfSupply,HSNCode</FETCH>
</COLLECTION>
</TDLMESSAGE>
</TDL>
</DESC>
</BODY>
</ENVELOPE>"""
response = self.tally._post_xml(request_xml)
root = ET.fromstring(_clean_xml_response(response).encode("utf-8"))
names = {}
for element in root.iter():
if _tag(element) in {"STOCKITEM", "STOCKITEM.LIST"}:
name = ""
unit = ""
for child in element.iter():
ctag = _tag(child)
value = (child.text or "").strip()
if not value:
continue
if ctag in {"NAME", "STOCKITEMNAME"} and not name:
name = value
elif ctag in {"BASEUNITS", "BASEUNIT"} and not unit:
unit = value
if name:
names[name.casefold()] = {"name": name, "unit": unit}
return names
def _normalize_items(self, company_name: str, voucher_type: str, items: list[dict]):
if not items:
return []
vtype = str(voucher_type or "").strip().title()
if vtype not in {"Purchase", "Sales"}:
raise ValueError("Stock-item lines are permitted only in Purchase or Sales vouchers.")
available = self._stock_item_names(company_name)
normalized = []
for index, item in enumerate(items or []):
source_name = str(item.get("stock_item_name") or "").strip()
if not source_name:
raise ValueError(f"Stock item line {index + 1}: item name is required.")
matched = available.get(source_name.casefold())
if matched is None:
# Conservative token matching only when it produces exactly one result.
source_tokens = {
token for token in re.split(r"[^A-Z0-9]+", source_name.upper())
if len(token) >= 4
}
candidates = []
for key, row in available.items():
name_tokens = {
token for token in re.split(r"[^A-Z0-9]+", row["name"].upper())
if len(token) >= 4
}
common = source_tokens & name_tokens
if common:
candidates.append((len(common), row))
candidates.sort(key=lambda pair: (-pair[0], pair[1]["name"]))
if candidates:
top_score = candidates[0][0]
top = [row for score, row in candidates if score == top_score]
if len(top) == 1:
matched = top[0]
if matched is None:
raise ValueError(
f"Stock item '{source_name}' does not exist in the open Tally company. "
"Create/map the stock item before posting; Phase 18 will not create masters automatically."
)
qty = abs(float(item.get("quantity") or 0))
rate = abs(float(item.get("rate") or 0))
amount = round(abs(float(item.get("amount") or 0)), 2)
unit = str(item.get("unit") or matched.get("unit") or "").strip()
accounting_ledger = str(item.get("accounting_ledger_name") or "").strip()
godown = str(item.get("godown_name") or "").strip()
side = str(item.get("dr_cr") or ("DR" if vtype == "Purchase" else "CR")).upper()
if amount <= 0:
raise ValueError(f"Stock item line {index + 1}: amount must be greater than zero.")
if qty < 0:
raise ValueError(f"Stock item line {index + 1}: quantity is invalid.")
if not accounting_ledger:
raise ValueError(f"Stock item line {index + 1}: accounting ledger is required.")
if side not in {"DR", "CR"}:
raise ValueError(f"Stock item line {index + 1}: DR/CR is invalid.")
if not rate and qty:
rate = amount / qty
normalized.append(
{
"stock_item_name": matched["name"],
"quantity": qty,
"unit": unit,
"rate": rate,
"amount": amount,
"dr_cr": side,
"accounting_ledger_name": accounting_ledger,
"godown_name": godown,
}
)
return normalized
def find_duplicates(
self,
company_name: str,
@@ -160,12 +283,14 @@ class NativeVoucherEngine:
reference: str,
total_amount: float,
lines: list[dict],
items: list[dict] | None = None,
):
vtype = str(voucher_type or "").strip().title()
if vtype not in VALID_VOUCHER_TYPES:
raise ValueError("Unsupported native Tally voucher type.")
normalized, total_dr, total_cr = self._normalize_lines(company_name, lines)
normalized_items = self._normalize_items(company_name, vtype, list(items or []))
duplicates = self.find_duplicates(
company_name,
voucher_type=vtype,
@@ -177,12 +302,80 @@ class NativeVoucherEngine:
return {
"voucher_type": vtype,
"line_count": len(normalized),
"stock_item_count": len(normalized_items),
"total_debit": total_dr,
"total_credit": total_cr,
"duplicate_candidates": duplicates,
"validated_ledgers": [row["ledger_name"] for row in normalized],
"validated_stock_items": [row["stock_item_name"] for row in normalized_items],
}
def _ledger_xml(self, row):
deemed_positive = "Yes" if row["dr_cr"] == "DR" else "No"
signed_amount = (
f"-{row['amount']:.2f}"
if row["dr_cr"] == "DR"
else f"{row['amount']:.2f}"
)
return (
"<ALLLEDGERENTRIES.LIST>"
f"<LEDGERNAME>{self.tally._xml_escape(row['ledger_name'])}</LEDGERNAME>"
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
f"<AMOUNT>{signed_amount}</AMOUNT>"
"</ALLLEDGERENTRIES.LIST>"
)
def _inventory_xml(self, item):
deemed_positive = "Yes" if item["dr_cr"] == "DR" else "No"
signed_amount = (
f"-{item['amount']:.2f}"
if item["dr_cr"] == "DR"
else f"{item['amount']:.2f}"
)
quantity_xml = ""
rate_xml = ""
if item["quantity"] and item["unit"]:
qty_text = f"{item['quantity']:g} {self.tally._xml_escape(item['unit'])}"
quantity_xml = (
f"<ACTUALQTY>{qty_text}</ACTUALQTY>"
f"<BILLEDQTY>{qty_text}</BILLEDQTY>"
)
if item["rate"] and item["unit"]:
rate_xml = (
f"<RATE>{item['rate']:g}/{self.tally._xml_escape(item['unit'])}</RATE>"
)
godown_xml = ""
if item["godown_name"]:
godown_xml = (
"<BATCHALLOCATIONS.LIST>"
f"<GODOWNNAME>{self.tally._xml_escape(item['godown_name'])}</GODOWNNAME>"
f"<AMOUNT>{signed_amount}</AMOUNT>"
f"{quantity_xml}"
"</BATCHALLOCATIONS.LIST>"
)
accounting_xml = (
"<ACCOUNTINGALLOCATIONS.LIST>"
f"<LEDGERNAME>{self.tally._xml_escape(item['accounting_ledger_name'])}</LEDGERNAME>"
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
f"<AMOUNT>{signed_amount}</AMOUNT>"
"</ACCOUNTINGALLOCATIONS.LIST>"
)
return (
"<ALLINVENTORYENTRIES.LIST>"
f"<STOCKITEMNAME>{self.tally._xml_escape(item['stock_item_name'])}</STOCKITEMNAME>"
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
f"{rate_xml}"
f"<AMOUNT>{signed_amount}</AMOUNT>"
f"{quantity_xml}"
f"{godown_xml}"
f"{accounting_xml}"
"</ALLINVENTORYENTRIES.LIST>"
)
def build_xml(
self,
company_name: str,
@@ -192,6 +385,7 @@ class NativeVoucherEngine:
reference: str,
narration: str,
lines: list[dict],
items: list[dict] | None = None,
):
vtype = str(voucher_type or "").strip().title()
if vtype not in VALID_VOUCHER_TYPES:
@@ -202,29 +396,31 @@ class NativeVoucherEngine:
raise ValueError("Tally company name is required.")
normalized, total_dr, total_cr = self._normalize_lines(company, lines)
normalized_items = self._normalize_items(company, vtype, list(items or []))
entries = []
# When stock items are present, their purchase/sales accounting ledger
# allocation is carried inside ALLINVENTORYENTRIES.LIST. Do not duplicate
# that same accounting ledger as an ALLLEDGERENTRIES amount.
item_ledger_names = {
item["accounting_ledger_name"].casefold()
for item in normalized_items
if item["accounting_ledger_name"]
}
ledger_entries = []
for row in normalized:
if row["dr_cr"] == "DR":
deemed_positive = "Yes"
signed_amount = f"-{row['amount']:.2f}"
else:
deemed_positive = "No"
signed_amount = f"{row['amount']:.2f}"
if normalized_items and row["ledger_name"].casefold() in item_ledger_names:
continue
ledger_entries.append(self._ledger_xml(row))
entries.append(
"<ALLLEDGERENTRIES.LIST>"
f"<LEDGERNAME>{self.tally._xml_escape(row['ledger_name'])}</LEDGERNAME>"
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
f"<AMOUNT>{signed_amount}</AMOUNT>"
"</ALLLEDGERENTRIES.LIST>"
)
inventory_entries = [self._inventory_xml(item) for item in normalized_items]
ref_xml = (
f"<REFERENCE>{self.tally._xml_escape(reference)}</REFERENCE>"
if str(reference or "").strip()
else ""
)
voucher_view = "Invoice Voucher View" if normalized_items else "Accounting Voucher View"
invoice_xml = "<ISINVOICE>Yes</ISINVOICE>" if normalized_items else ""
return f"""<ENVELOPE>
<HEADER>
@@ -241,13 +437,15 @@ class NativeVoucherEngine:
</DESC>
<DATA>
<TALLYMESSAGE xmlns:UDF="TallyUDF">
<VOUCHER VCHTYPE="{vtype}" ACTION="Create" OBJVIEW="Accounting Voucher View">
<VOUCHER VCHTYPE="{vtype}" ACTION="Create" OBJVIEW="{voucher_view}">
<DATE>{_tally_date(voucher_date)}</DATE>
<VOUCHERTYPENAME>{vtype}</VOUCHERTYPENAME>
<PERSISTEDVIEW>Accounting Voucher View</PERSISTEDVIEW>
<PERSISTEDVIEW>{voucher_view}</PERSISTEDVIEW>
{invoice_xml}
{ref_xml}
<NARRATION>{self.tally._xml_escape(narration)}</NARRATION>
{''.join(entries)}
{''.join(ledger_entries)}
{''.join(inventory_entries)}
</VOUCHER>
</TALLYMESSAGE>
</DATA>
@@ -263,6 +461,7 @@ class NativeVoucherEngine:
reference: str,
narration: str,
lines: list[dict],
items: list[dict] | None = None,
):
request_xml = self.build_xml(
company_name,
@@ -271,6 +470,7 @@ class NativeVoucherEngine:
reference=reference,
narration=narration,
lines=lines,
items=items,
)
response_xml = self.tally._post_xml(request_xml)
root = ET.fromstring(_clean_xml_response(response_xml).encode("utf-8"))
+2
View File
@@ -52,6 +52,7 @@ from app.modules.accounting.sales_learning_ui import router as accounting_sales_
from app.modules.accounting.sales_menu_ui import router as accounting_sales_menu_ui_router
from app.modules.accounting.chart_ui import router as accounting_chart_ui_router
from app.modules.accounting.voucher_engine_ui import router as accounting_voucher_engine_ui_router
from app.modules.accounting.automatic_voucher_ui import router as accounting_automatic_voucher_ui_router
from app.modules.registrations.ui import router as registrations_ui_router
from app.modules.credential_vault.ui import router as credential_vault_ui_router
from app.modules.client_identity.ui import router as client_identity_ui_router
@@ -90,6 +91,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(accounting_sales_menu_ui_router)
app.include_router(accounting_chart_ui_router)
app.include_router(accounting_voucher_engine_ui_router)
app.include_router(accounting_automatic_voucher_ui_router)
app.include_router(work_tracker_ui_router)
app.include_router(billing_ui_router)
app.include_router(platform_billing_ui_router)