diff --git a/alembic/versions/20260823_native_voucher_engine_phase17.py b/alembic/versions/20260823_native_voucher_engine_phase17.py new file mode 100644 index 0000000..e40d7f3 --- /dev/null +++ b/alembic/versions/20260823_native_voucher_engine_phase17.py @@ -0,0 +1,79 @@ +"""Phase 17 Common Native Tally Voucher Engine. + +Revision ID: 20260823_native_voucher_engine_p17 +Revises: 20260823_chart_of_accounts_p16 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "20260823_native_voucher_engine_p17" +down_revision = "20260823_chart_of_accounts_p16" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "accounting_voucher_drafts", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("tally_guid", sa.String(120), nullable=False), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + sa.Column("voucher_type", sa.String(30), nullable=False), + sa.Column("business_nature", sa.String(60), nullable=False, server_default=""), + sa.Column("voucher_date", sa.String(10), nullable=False), + sa.Column("reference", sa.String(180), nullable=False, server_default=""), + sa.Column("narration", sa.Text(), nullable=False, server_default=""), + sa.Column("source_type", sa.String(60), nullable=False, server_default="manual"), + sa.Column("source_record_type", sa.String(80), nullable=False, server_default=""), + sa.Column("source_record_id", sa.String(120), nullable=False, server_default=""), + sa.Column("source_evidence_json", sa.Text(), nullable=False, server_default="{}"), + sa.Column("audit_area", sa.String(120), nullable=False, server_default=""), + sa.Column("audit_flags_json", sa.Text(), nullable=False, server_default="[]"), + sa.Column("total_debit", sa.Float(), nullable=False, server_default="0"), + sa.Column("total_credit", sa.Float(), nullable=False, server_default="0"), + sa.Column("status", sa.String(30), nullable=False, server_default="draft"), + sa.Column("review_note", sa.Text(), nullable=False, server_default=""), + sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("approved_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("approved_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("workstation_agent_id", sa.Integer(), sa.ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True), + sa.Column("preflight_job_id", sa.Integer(), sa.ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True), + sa.Column("posting_job_id", sa.Integer(), sa.ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True), + sa.Column("preflight_result_json", sa.Text(), nullable=False, server_default="{}"), + sa.Column("posting_result_json", sa.Text(), nullable=False, server_default="{}"), + sa.Column("tally_voucher_id", sa.String(120), nullable=False, server_default=""), + sa.Column("tally_voucher_number", sa.String(160), nullable=False, server_default=""), + sa.Column("last_error", sa.Text(), nullable=False, server_default=""), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + for name in ( + "tenant_id", "client_id", "tally_guid", "voucher_type", "business_nature", + "voucher_date", "reference", "source_type", "audit_area", "status", + "workstation_agent_id", "preflight_job_id", "posting_job_id", "created_at_utc", + ): + op.create_index(f"ix_accounting_voucher_drafts_{name}", "accounting_voucher_drafts", [name]) + + op.create_table( + "accounting_voucher_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("ledger_name", sa.String(255), nullable=False), + sa.Column("ledger_role_code", sa.String(50), nullable=False, server_default=""), + sa.Column("dr_cr", sa.String(2), nullable=False), + sa.Column("amount", sa.Float(), nullable=False, server_default="0"), + sa.Column("line_note", sa.Text(), nullable=False, server_default=""), + ) + for name in ("draft_id", "ledger_name", "ledger_role_code"): + op.create_index(f"ix_accounting_voucher_lines_{name}", "accounting_voucher_lines", [name]) + + +def downgrade(): + op.drop_table("accounting_voucher_lines") + op.drop_table("accounting_voucher_drafts") diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 0bd8a04..906331f 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -20,6 +20,7 @@
Chart of Accounts - Groups & Ledgers + Accounting Voucher Engine
diff --git a/app/modules/accounting/templates/accounting/voucher_engine.html b/app/modules/accounting/templates/accounting/voucher_engine.html new file mode 100644 index 0000000..512e5e9 --- /dev/null +++ b/app/modules/accounting/templates/accounting/voucher_engine.html @@ -0,0 +1,339 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Accounting Engine · Phase 17

+

Common Native Tally Voucher Engine

+

+ Prepare one balanced accounting transaction and post it with the correct native Tally voucher type: Purchase, Sales, Receipt, Payment, Contra or Journal. Existing specialized accounting tools remain unchanged and can progressively feed this common engine. +

+
+ +
+ + {% if message %}
{{ message }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} + +
+
+ + + + +
+
+
+ + {% if selected_client and selected_tally_guid %} +
+

Native Tally voucher selection

+
+
Purchase
Supplier bills, purchases, direct/indirect expenses and asset bills.
+
Sales
Customer invoices, sales and billed direct/indirect income.
+
Receipt
Money received into Bank/Cash from customers, income, loans or capital.
+
Payment
Money paid from Bank/Cash to suppliers, expenses, assets or loans.
+
Contra
Bank-to-bank, bank-to-cash and cash-to-bank transfers.
+
Journal
Accruals, provisions, depreciation, reclassification and adjustments.
+
+
+ +
+
+

Create Voucher Draft

+

Every ledger must already exist in the Phase 16 Chart of Accounts. Debit and Credit must balance before the draft is accepted.

+
+ +
+ + + + +
+ + + + + +
+ + + +
+
+
+

Voucher Lines

+

Add as many balanced Debit/Credit lines as needed.

+
+ +
+ +
+ {% for index in range(2) %} +
+ + + + + +
+ {% endfor %} +
+ +
+ Debit: ₹0.00 + Credit: ₹0.00 + Not balanced +
+
+ + +
+
+ {% elif selected_client %} +
+ Refresh the client's Chart of Accounts and select a mapped Tally company before creating vouchers. +
+ {% endif %} + + {% if selected_client %} +
+
+
+
+

Voucher Review & Posting Queue

+

{{ total }} draft(s). Controlled flow: Draft → Reviewed → Approved → Tally Preflight → Post.

+
+
+ + + +
+
+
+ +
+ {% for draft in drafts %} +
+
+
+
+ {{ draft.voucher_type }} + {{ draft.status|replace('_',' ')|title }} + Draft #{{ draft.id }} +
+
{{ draft.company_name }} · {{ draft.voucher_date }}{% if draft.reference %} · Ref {{ draft.reference }}{% endif %}
+
Nature: {{ draft.business_nature|replace('_',' ')|title }} · Source: {{ draft.source_type }} · Audit use: {{ draft.audit_area or 'General Ledger' }}
+ {% if draft.narration %}
{{ draft.narration }}
{% endif %} +
+
+
₹{{ '%.2f'|format(draft.total_debit) }}
+ {% if draft.tally_voucher_number %}
Tally voucher {{ draft.tally_voucher_number }}
{% endif %} +
+
+ +
+ + + + {% for line in draft.lines %} + + + + + + + {% endfor %} + +
LedgerRoleDr/CrAmount
{{ line.ledger_name }}{{ role_labels.get(line.ledger_role_code, line.ledger_role_code) }}{{ line.dr_cr }}₹{{ '%.2f'|format(line.amount) }}
+
+ + {% if draft.last_error %}
{{ draft.last_error }}
{% endif %} + {% if draft.status == 'preflight_ready' %}
Tally preflight completed. Ledger existence, balancing and duplicate checks completed.
{% endif %} + +
+ {% if draft.status == 'draft' %} +
+ + + +
+ {% endif %} + + {% if draft.status == 'reviewed' %} +
+ + +
+ {% endif %} + + {% if draft.status in ['approved','preflight_failed'] %} +
+ + + +
+ {% endif %} + + {% if draft.status == 'preflight_ready' %} +
+ + +
+ {% endif %} + + {% if draft.status not in ['posted','void','preflight_queued','preflight_claimed','posting_queued','posting_claimed'] %} +
+ + +
+ {% endif %} +
+
+ {% else %} +
No voucher drafts for this client yet.
+ {% endfor %} +
+ + {% if pages > 1 %} +
+ Page {{ page }} of {{ pages }} +
+ {% if page > 1 %}Previous{% endif %} + {% if page < pages %}Next{% endif %} +
+
+ {% endif %} +
+ {% endif %} +
+ +{% if selected_client and selected_tally_guid %} + +{% endif %} +{% endblock %} diff --git a/app/modules/accounting/voucher_engine_models.py b/app/modules/accounting/voucher_engine_models.py new file mode 100644 index 0000000..a61ad46 --- /dev/null +++ b/app/modules/accounting/voucher_engine_models.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.common import CommonBase + + +def _utcnow(): + return datetime.now(timezone.utc) + + +class AccountingVoucherDraft(CommonBase): + __tablename__ = "accounting_voucher_drafts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + voucher_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + business_nature: Mapped[str] = mapped_column(String(60), nullable=False, default="", index=True) + voucher_date: Mapped[str] = mapped_column(String(10), nullable=False, index=True) + reference: Mapped[str] = mapped_column(String(180), nullable=False, default="", index=True) + narration: Mapped[str] = mapped_column(Text, nullable=False, default="") + + source_type: Mapped[str] = mapped_column(String(60), nullable=False, default="manual", index=True) + source_record_type: Mapped[str] = mapped_column(String(80), nullable=False, default="") + source_record_id: Mapped[str] = mapped_column(String(120), nullable=False, default="") + source_evidence_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + + audit_area: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True) + audit_flags_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + + total_debit: Mapped[float] = mapped_column(Float, nullable=False, default=0) + total_credit: Mapped[float] = mapped_column(Float, nullable=False, default=0) + + status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft", index=True) + review_note: Mapped[str] = mapped_column(Text, nullable=False, default="") + reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + approved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + workstation_agent_id: Mapped[int | None] = mapped_column(ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True, index=True) + preflight_job_id: Mapped[int | None] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True, index=True) + posting_job_id: Mapped[int | None] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True, index=True) + preflight_result_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + posting_result_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + tally_voucher_id: Mapped[str] = mapped_column(String(120), nullable=False, default="") + tally_voucher_number: Mapped[str] = mapped_column(String(160), nullable=False, default="") + last_error: Mapped[str] = mapped_column(Text, nullable=False, default="") + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, index=True) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow) + + lines: Mapped[list["AccountingVoucherLine"]] = relationship( + "AccountingVoucherLine", + back_populates="draft", + cascade="all, delete-orphan", + order_by="AccountingVoucherLine.line_no", + ) + + +class AccountingVoucherLine(CommonBase): + __tablename__ = "accounting_voucher_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) + ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + ledger_role_code: Mapped[str] = mapped_column(String(50), nullable=False, default="", index=True) + dr_cr: Mapped[str] = mapped_column(String(2), nullable=False) + amount: Mapped[float] = mapped_column(Float, nullable=False, default=0) + line_note: Mapped[str] = mapped_column(Text, nullable=False, default="") + + draft: Mapped[AccountingVoucherDraft] = relationship("AccountingVoucherDraft", back_populates="lines") diff --git a/app/modules/accounting/voucher_engine_service.py b/app/modules/accounting/voucher_engine_service.py new file mode 100644 index 0000000..0748109 --- /dev/null +++ b/app/modules/accounting/voucher_engine_service.py @@ -0,0 +1,572 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone + +from sqlalchemy import func, select +from sqlalchemy.orm import selectinload + +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, AccountingVoucherLine +from app.modules.documents.agent_jobs import enqueue_agent_job +from app.modules.documents.models import ERPAgentJob, ERPWorkstationAgent + + +VOUCHER_TYPES = ("Purchase", "Sales", "Receipt", "Payment", "Contra", "Journal") +BUSINESS_NATURES = ( + ("PURCHASE", "Purchase"), + ("DIRECT_EXPENSE", "Direct Expense"), + ("INDIRECT_EXPENSE", "Indirect Expense"), + ("SALES", "Sales"), + ("DIRECT_INCOME", "Direct Income"), + ("INDIRECT_INCOME", "Indirect / Other Income"), + ("RECEIPT", "Receipt"), + ("PAYMENT", "Payment"), + ("CONTRA", "Contra"), + ("ADJUSTMENT", "Adjustment / Journal"), +) + +PREFLIGHT_ACTION = "accounting_native_voucher_preflight" +POST_ACTION = "accounting_post_native_voucher" + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _s(value): + return str(value or "").strip() + + +def _loads(value, default=None): + try: + return json.loads(value or "") + except Exception: + return default if default is not None else {} + + +def _dumps(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) + + +def suggested_voucher_type(business_nature: str) -> str: + nature = _s(business_nature).upper() + return { + "PURCHASE": "Purchase", + "DIRECT_EXPENSE": "Purchase", + "INDIRECT_EXPENSE": "Purchase", + "SALES": "Sales", + "DIRECT_INCOME": "Sales", + "INDIRECT_INCOME": "Sales", + "RECEIPT": "Receipt", + "PAYMENT": "Payment", + "CONTRA": "Contra", + "ADJUSTMENT": "Journal", + }.get(nature, "Journal") + + +def chart_ledgers(db, *, tenant_id: int, client_id: int, tally_guid: str): + return list( + db.execute( + select(AccountingChartLedger) + .where( + AccountingChartLedger.tenant_id == int(tenant_id), + AccountingChartLedger.client_id == int(client_id), + AccountingChartLedger.tally_guid == _s(tally_guid), + ) + .order_by(AccountingChartLedger.name) + ).scalars().all() + ) + + +def _ledger_map(db, *, tenant_id: int, client_id: int, tally_guid: str): + return { + row.name.casefold(): row + for row in chart_ledgers( + db, + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + ) + } + + +def _normalize_lines( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str, + ledger_names, + dr_cr_values, + amounts, + notes, +): + ledger_map = _ledger_map( + db, + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + ) + if not ledger_map: + raise ValueError( + "No Chart of Accounts is available for this mapped Tally company. " + "Refresh Chart of Accounts first." + ) + + ledger_names = list(ledger_names or []) + dr_cr_values = list(dr_cr_values or []) + amounts = list(amounts or []) + notes = list(notes or []) + + result = [] + for index, ledger_name in enumerate(ledger_names): + name = _s(ledger_name) + if not name: + continue + + side = _s(dr_cr_values[index] if index < len(dr_cr_values) else "").upper() + if side not in {"DR", "CR"}: + raise ValueError(f"Line {index + 1}: debit/credit side is invalid.") + + try: + amount = round(abs(float(amounts[index] if index < len(amounts) else 0)), 2) + except Exception: + amount = 0 + if amount <= 0: + raise ValueError(f"Line {index + 1}: amount must be greater than zero.") + + ledger = ledger_map.get(name.casefold()) + if not ledger: + raise ValueError( + f"Line {index + 1}: ledger '{name}' is not in the synchronized Chart of Accounts." + ) + + result.append( + { + "line_no": len(result) + 1, + "ledger_name": ledger.name, + "ledger_role_code": effective_role(ledger), + "dr_cr": side, + "amount": amount, + "line_note": _s(notes[index] if index < len(notes) else ""), + } + ) + + if len(result) < 2: + raise ValueError("At least two voucher lines are required.") + + total_dr = round(sum(row["amount"] for row in result if row["dr_cr"] == "DR"), 2) + total_cr = round(sum(row["amount"] for row in result if row["dr_cr"] == "CR"), 2) + + if total_dr <= 0 or total_cr <= 0: + raise ValueError("The voucher must contain at least one Debit and one Credit line.") + if abs(total_dr - total_cr) > 0.01: + raise ValueError( + f"Voucher is not balanced. Debit ₹{total_dr:.2f} != Credit ₹{total_cr:.2f}." + ) + + return result, total_dr, total_cr + + +def _validate_voucher_semantics(voucher_type: str, rows: list[dict]): + vtype = _s(voucher_type).title() + if vtype not in VOUCHER_TYPES: + raise ValueError("Voucher type must be Purchase, Sales, Receipt, Payment, Contra or Journal.") + + debit_roles = {row["ledger_role_code"] for row in rows if row["dr_cr"] == "DR"} + credit_roles = {row["ledger_role_code"] for row in rows if row["dr_cr"] == "CR"} + all_roles = debit_roles | credit_roles + bank_cash = {"BANK", "CASH"} + + if vtype == "Receipt" and not (debit_roles & bank_cash): + raise ValueError("Receipt voucher requires Bank/Cash on the Debit side.") + + if vtype == "Payment" and not (credit_roles & bank_cash): + raise ValueError("Payment voucher requires Bank/Cash on the Credit side.") + + if vtype == "Contra": + if not all_roles or not all_roles.issubset(bank_cash): + raise ValueError("Contra voucher may contain only Bank/Cash ledgers.") + if not (debit_roles & bank_cash) or not (credit_roles & bank_cash): + raise ValueError("Contra voucher requires Bank/Cash on both Debit and Credit sides.") + + if vtype == "Purchase": + allowed = { + "PURCHASE", + "DIRECT_EXPENSE", + "INDIRECT_EXPENSE", + "FIXED_ASSET", + "CURRENT_ASSET", + "INVENTORY", + "GST_INPUT", + } + if not (debit_roles & allowed): + raise ValueError( + "Purchase voucher requires a Purchase/Expense/Asset/Input-tax ledger on the Debit side." + ) + + if vtype == "Sales": + allowed = {"SALES", "DIRECT_INCOME", "OTHER_INCOME", "GST_OUTPUT", "FREIGHT_CHARGE"} + if not (credit_roles & allowed): + raise ValueError( + "Sales voucher requires a Sales/Income/Output-tax/charge ledger on the Credit side." + ) + + +def _audit_projection(voucher_type: str, rows: list[dict]): + roles = {row["ledger_role_code"] for row in rows} + areas = [] + flags = [] + + 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 & {"FIXED_ASSET", "DEPRECIATION"}: + areas.append("Property, Plant & Equipment") + if roles & {"GST_INPUT", "GST_OUTPUT", "TAX_OTHER"}: + areas.append("GST & Taxes") + flags.append("tax-ledger-present") + if roles & {"CUSTOMER"}: + areas.append("Trade Receivables") + if roles & {"LOAN", "CAPITAL", "RESERVE"}: + areas.append("Equity & Borrowings") + if voucher_type == "Journal": + flags.append("journal-adjustment") + + if not areas: + areas.append("General Ledger") + + return " · ".join(dict.fromkeys(areas)), flags + + +def create_draft( + db, + *, + tenant_id: int, + client_id: int, + tally_guid: str, + company_name: str, + voucher_type: str, + business_nature: str, + voucher_date: str, + reference: str, + narration: str, + source_type: str, + ledger_names, + dr_cr_values, + amounts, + notes, + user_id: int, +): + vtype = _s(voucher_type).title() + rows, total_dr, total_cr = _normalize_lines( + db, + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + ledger_names=ledger_names, + dr_cr_values=dr_cr_values, + amounts=amounts, + notes=notes, + ) + _validate_voucher_semantics(vtype, rows) + audit_area, audit_flags = _audit_projection(vtype, rows) + + draft = AccountingVoucherDraft( + tenant_id=int(tenant_id), + client_id=int(client_id), + tally_guid=_s(tally_guid), + company_name=_s(company_name), + voucher_type=vtype, + business_nature=_s(business_nature).upper(), + voucher_date=_s(voucher_date), + reference=_s(reference), + narration=_s(narration), + source_type=_s(source_type) or "manual", + audit_area=audit_area, + audit_flags_json=_dumps(audit_flags), + total_debit=total_dr, + total_credit=total_cr, + status="draft", + created_by_user_id=int(user_id), + updated_at_utc=_utcnow(), + ) + + for row in rows: + draft.lines.append(AccountingVoucherLine(**row)) + + db.add(draft) + db.commit() + db.refresh(draft) + return draft + + +def get_draft(db, *, tenant_id: int, client_id: int, draft_id: int): + return db.execute( + select(AccountingVoucherDraft) + .options(selectinload(AccountingVoucherDraft.lines)) + .where( + AccountingVoucherDraft.id == int(draft_id), + AccountingVoucherDraft.tenant_id == int(tenant_id), + AccountingVoucherDraft.client_id == int(client_id), + ) + ).scalar_one_or_none() + + +def list_drafts(db, *, tenant_id: int, client_id: int, status: str = "", page: int = 1, per_page: int = 20): + where = [ + AccountingVoucherDraft.tenant_id == int(tenant_id), + AccountingVoucherDraft.client_id == int(client_id), + ] + if _s(status): + where.append(AccountingVoucherDraft.status == _s(status)) + + total = int(db.scalar(select(func.count(AccountingVoucherDraft.id)).where(*where)) or 0) + per_page = max(10, min(100, int(per_page or 20))) + pages = max(1, (total + per_page - 1) // per_page) + page = max(1, min(int(page or 1), pages)) + + rows = list( + db.execute( + select(AccountingVoucherDraft) + .options(selectinload(AccountingVoucherDraft.lines)) + .where(*where) + .order_by(AccountingVoucherDraft.id.desc()) + .offset((page - 1) * per_page) + .limit(per_page) + ).scalars().all() + ) + return rows, total, page, pages + + +def review_draft(db, *, draft: AccountingVoucherDraft, note: str, user_id: int): + if draft.status != "draft": + raise ValueError("Only Draft vouchers can be marked Reviewed.") + draft.status = "reviewed" + draft.review_note = _s(note) + draft.reviewed_by_user_id = int(user_id) + draft.reviewed_at_utc = _utcnow() + draft.updated_at_utc = _utcnow() + db.add(draft) + db.commit() + return draft + + +def approve_draft(db, *, draft: AccountingVoucherDraft, user_id: int): + if draft.status != "reviewed": + raise ValueError("Voucher must be Reviewed before Approval.") + draft.status = "approved" + draft.approved_by_user_id = int(user_id) + draft.approved_at_utc = _utcnow() + draft.updated_at_utc = _utcnow() + db.add(draft) + db.commit() + return draft + + +def void_draft(db, *, draft: AccountingVoucherDraft): + if draft.status == "posted": + raise ValueError("A posted voucher cannot be voided from the ERP draft queue.") + if draft.status in {"preflight_queued", "preflight_claimed", "posting_queued", "posting_claimed"}: + raise ValueError("An active Local Agent job exists for this voucher.") + draft.status = "void" + draft.updated_at_utc = _utcnow() + db.add(draft) + db.commit() + return draft + + +def visible_workstations(db, *, tenant_id: int, branch_id: int | None = None): + stmt = select(ERPWorkstationAgent).where( + ERPWorkstationAgent.tenant_id == int(tenant_id), + ERPWorkstationAgent.is_active.is_(True), + ERPWorkstationAgent.tally_connected.is_(True), + ) + if branch_id is not None: + stmt = stmt.where(ERPWorkstationAgent.branch_id == int(branch_id)) + + return list( + db.execute( + stmt.order_by( + ERPWorkstationAgent.last_seen_at_utc.desc(), + ERPWorkstationAgent.id.desc(), + ) + ).scalars().all() + ) + + +def _idempotency(draft: AccountingVoucherDraft): + raw = "|".join( + [ + str(draft.tenant_id), + str(draft.client_id), + str(draft.id), + draft.tally_guid, + draft.voucher_type, + draft.voucher_date, + draft.reference, + f"{draft.total_debit:.2f}", + ] + ) + return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest()[:32] + + +def _payload(draft: AccountingVoucherDraft): + return { + "client_id": int(draft.client_id), + "tenant_id": int(draft.tenant_id), + "tally_guid": draft.tally_guid, + "company_name": draft.company_name, + "voucher_type": draft.voucher_type, + "voucher_date": draft.voucher_date, + "reference": draft.reference, + "narration": draft.narration, + "business_nature": draft.business_nature, + "erp_voucher_draft_id": int(draft.id), + "total_amount": float(draft.total_debit), + "lines": [ + { + "ledger_name": line.ledger_name, + "ledger_role_code": line.ledger_role_code, + "dr_cr": line.dr_cr, + "amount": float(line.amount), + "line_note": line.line_note, + } + for line in draft.lines + ], + } + + +def queue_preflight(db, *, draft: AccountingVoucherDraft, workstation_id: int, user_id: int): + if draft.status != "approved": + raise ValueError("Voucher must be Approved before Local Agent preflight.") + + workstation = db.get(ERPWorkstationAgent, int(workstation_id)) + if ( + not workstation + or not workstation.is_active + or int(workstation.tenant_id) != int(draft.tenant_id) + or not workstation.tally_connected + ): + raise ValueError("Selected workstation is unavailable or Tally is not connected.") + + job = enqueue_agent_job( + db, + workstation_agent_id=workstation.id, + action=PREFLIGHT_ACTION, + payload=_payload(draft), + idempotency_key=f"native-voucher:{_idempotency(draft)}:preflight:{workstation.id}", + priority=9, + max_attempts=2, + created_by_user_id=user_id, + ) + + draft.workstation_agent_id = workstation.id + draft.preflight_job_id = job.id + draft.preflight_result_json = "{}" + draft.last_error = "" + draft.status = "preflight_queued" + draft.updated_at_utc = _utcnow() + db.add(draft) + db.commit() + return draft + + +def queue_post(db, *, draft: AccountingVoucherDraft, user_id: int): + sync_draft(db, draft) + if draft.status != "preflight_ready": + raise ValueError("Successful workstation/Tally preflight is required before posting.") + + preflight = _loads(draft.preflight_result_json, {}) + if preflight.get("duplicate_candidates"): + raise ValueError("Possible duplicate voucher exists in Tally. Posting is blocked.") + + workstation = db.get(ERPWorkstationAgent, int(draft.workstation_agent_id or 0)) + if not workstation or not workstation.is_active or not workstation.tally_connected: + raise ValueError("The preflight workstation is no longer available or connected to Tally.") + + job = enqueue_agent_job( + db, + workstation_agent_id=workstation.id, + action=POST_ACTION, + payload=_payload(draft), + idempotency_key=f"native-voucher:{_idempotency(draft)}:post", + priority=10, + max_attempts=1, + created_by_user_id=user_id, + ) + + draft.posting_job_id = job.id + draft.status = "posting_queued" + draft.last_error = "" + draft.updated_at_utc = _utcnow() + db.add(draft) + db.commit() + return draft + + +def sync_draft(db, draft: AccountingVoucherDraft): + changed = False + + if draft.preflight_job_id and draft.status.startswith("preflight"): + job = db.get(ERPAgentJob, int(draft.preflight_job_id)) + if job: + if job.status == "claimed" and draft.status != "preflight_claimed": + draft.status = "preflight_claimed" + changed = True + elif job.status == "succeeded": + result = _loads(job.result_json, {}) + draft.preflight_result_json = _dumps(result) + draft.company_name = _s(result.get("company_name")) or draft.company_name + draft.status = "preflight_ready" + draft.last_error = "" + changed = True + elif job.status in {"failed", "cancelled"}: + draft.status = "preflight_failed" + draft.last_error = _s(job.last_error) or f"Preflight job {job.status}." + changed = True + + if draft.posting_job_id and draft.status.startswith("posting"): + job = db.get(ERPAgentJob, int(draft.posting_job_id)) + if job: + if job.status == "claimed" and draft.status != "posting_claimed": + draft.status = "posting_claimed" + changed = True + elif job.status == "succeeded": + result = _loads(job.result_json, {}) + tally = result.get("tally_result") or result + draft.posting_result_json = _dumps(result) + draft.tally_voucher_id = _s( + tally.get("last_voucher_id") or tally.get("voucher_id") + )[:120] + draft.tally_voucher_number = _s( + tally.get("voucher_number") + or tally.get("last_voucher_id") + or tally.get("voucher_id") + )[:160] + draft.status = "posted" + draft.last_error = "" + changed = True + elif job.status in {"failed", "cancelled"}: + draft.status = "posting_failed" + draft.last_error = _s(job.last_error) or f"Posting job {job.status}." + changed = True + + if changed: + draft.updated_at_utc = _utcnow() + db.add(draft) + db.commit() + + return draft + + +def sync_drafts(db, drafts): + for draft in drafts: + if draft.status.startswith("preflight") or draft.status.startswith("posting"): + sync_draft(db, draft) diff --git a/app/modules/accounting/voucher_engine_ui.py b/app/modules/accounting/voucher_engine_ui.py new file mode 100644 index 0000000..3757598 --- /dev/null +++ b/app/modules/accounting/voucher_engine_ui.py @@ -0,0 +1,355 @@ +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.chart_models import AccountingChartLedger +from app.modules.accounting.chart_service import ROLE_LABELS +from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients +from app.modules.accounting.voucher_engine_service import ( + BUSINESS_NATURES, + VOUCHER_TYPES, + approve_draft, + chart_ledgers, + create_draft, + get_draft, + list_drafts, + queue_post, + queue_preflight, + review_draft, + sync_drafts, + visible_workstations, + void_draft, +) +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles + +router = APIRouter(prefix="/tools/accounting/voucher-engine", tags=["accounting-native-voucher-engine-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" + ("?" + 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), "") + + +@router.get("") +def page( + request: Request, + client_id: int | None = None, + tally_guid: str = "", + status: str = "", + page: int = 1, + per_page: int = 20, + 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 = [] + ledgers = [] + drafts = [] + total = 0 + pages = 1 + workstations = [] + + 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 and tally_guid not in {row["guid"] for row in company_options}: + tally_guid = "" + + if tally_guid: + ledgers = chart_ledgers( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + ) + + drafts, total, page, pages = list_drafts( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + status=status, + page=page, + per_page=per_page, + ) + sync_drafts(db, drafts) + drafts, total, page, pages = list_drafts( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + status=status, + page=page, + per_page=per_page, + ) + workstations = visible_workstations( + db, + tenant_id=scope.tenant_id, + branch_id=scope.branch_id, + ) + + return templates.TemplateResponse( + "modules/accounting/templates/accounting/voucher_engine.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": "Accounting Voucher Engine", + "clients": clients, + "selected_client": selected, + "selected_tally_guid": tally_guid, + "company_options": company_options, + "ledgers": ledgers, + "role_labels": ROLE_LABELS, + "voucher_types": VOUCHER_TYPES, + "business_natures": BUSINESS_NATURES, + "drafts": drafts, + "total": total, + "page": page, + "pages": pages, + "per_page": per_page, + "status_filter": status, + "workstations": workstations, + "message": message, + "error": error, + }, + ) + finally: + db.close() + + +@router.post("/draft") +def create( + request: Request, + client_id: int = Form(...), + tally_guid: str = Form(...), + voucher_type: str = Form(...), + business_nature: str = Form(...), + voucher_date: str = Form(...), + reference: str = Form(""), + narration: str = Form(""), + source_type: str = Form("manual"), + ledger_name: list[str] = Form(...), + dr_cr: list[str] = Form(...), + amount: list[float] = Form(...), + line_note: list[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: + return _go(error="Client is not visible.") + + options = _company_options(db, scope.tenant_id, client.id) + company_name = _company_name(options, tally_guid) + if not company_name: + raise ValueError("Chart of Accounts for the selected Tally company is not available.") + + draft = create_draft( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + tally_guid=tally_guid, + company_name=company_name, + voucher_type=voucher_type, + business_nature=business_nature, + voucher_date=voucher_date, + reference=reference, + narration=narration, + source_type=source_type, + ledger_names=ledger_name, + dr_cr_values=dr_cr, + amounts=amount, + notes=line_note, + user_id=user.id, + ) + return _go(client.id, tally_guid=tally_guid, message=f"{draft.voucher_type} voucher draft #{draft.id} created.") + except Exception as exc: + db.rollback() + return _go(client_id, tally_guid=tally_guid, error=str(exc)) + finally: + db.close() + + +def _draft_for_action(db, request, user, client_id, draft_id): + client, _clients, scope = _find_visible_client(db, request, user, client_id) + if not client: + raise ValueError("Client is not visible.") + draft = get_draft( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + draft_id=draft_id, + ) + if not draft: + raise ValueError("Voucher draft was not found.") + return client, scope, draft + + +@router.post("/draft/{draft_id}/review") +def review( + request: Request, + draft_id: int, + client_id: int = Form(...), + note: 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, scope, draft = _draft_for_action(db, request, user, client_id, draft_id) + review_draft(db, draft=draft, note=note, user_id=user.id) + return _go(client.id, message=f"Voucher draft #{draft.id} reviewed.") + except Exception as exc: + db.rollback() + return _go(client_id, error=str(exc)) + finally: + db.close() + + +@router.post("/draft/{draft_id}/approve") +def approve( + request: Request, + draft_id: int, + client_id: int = 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, scope, draft = _draft_for_action(db, request, user, client_id, draft_id) + approve_draft(db, draft=draft, user_id=user.id) + return _go(client.id, message=f"Voucher draft #{draft.id} approved for Tally preflight.") + except Exception as exc: + db.rollback() + return _go(client_id, error=str(exc)) + finally: + db.close() + + +@router.post("/draft/{draft_id}/preflight") +def preflight( + request: Request, + draft_id: int, + client_id: int = Form(...), + workstation_id: int = 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, scope, draft = _draft_for_action(db, request, user, client_id, draft_id) + queue_preflight(db, draft=draft, workstation_id=workstation_id, user_id=user.id) + return _go(client.id, message=f"Voucher draft #{draft.id} Tally preflight queued.") + except Exception as exc: + db.rollback() + return _go(client_id, error=str(exc)) + finally: + db.close() + + +@router.post("/draft/{draft_id}/post") +def post( + request: Request, + draft_id: int, + client_id: int = 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, scope, draft = _draft_for_action(db, request, user, client_id, draft_id) + queue_post(db, draft=draft, user_id=user.id) + return _go(client.id, message=f"Voucher draft #{draft.id} controlled Tally posting queued.") + except Exception as exc: + db.rollback() + return _go(client_id, error=str(exc)) + finally: + db.close() + + +@router.post("/draft/{draft_id}/void") +def void( + request: Request, + draft_id: int, + client_id: int = 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, scope, draft = _draft_for_action(db, request, user, client_id, draft_id) + void_draft(db, draft=draft) + return _go(client.id, message=f"Voucher draft #{draft.id} voided.") + except Exception as exc: + db.rollback() + return _go(client_id, error=str(exc)) + finally: + db.close() diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 26f6601..a8ffeb7 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.16.0" +ERP_LOCAL_AGENT_VERSION = "1.17.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) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index ca33d77..be2362e 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.16.0" +__version__ = "1.17.0" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index 08c5fbb..6340275 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -7,6 +7,7 @@ from typing import Any from . import __version__ from .accounting_store import LocalAccountingStore from .tally import TallyLiveConnector +from .native_voucher_engine import NativeVoucherEngine class AgentCommandProcessor: @@ -49,6 +50,10 @@ class AgentCommandProcessor: result = self._bank_posting_preflight(payload) elif action == "accounting_post_bank_voucher": result = self._post_bank_voucher(payload) + elif action == "accounting_native_voucher_preflight": + result = self._native_voucher_preflight(payload) + elif action == "accounting_post_native_voucher": + result = self._post_native_voucher(payload) elif action == "accounting_depreciation_preview": result = self._depreciation_preview(payload) elif action == "accounting_calculate_it_depreciation": @@ -78,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, + "tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "tally_transaction_sync_capability": True, "historical_learning_read_capability": True, "purchase_posting_preflight_capability": True, @@ -555,6 +560,73 @@ class AgentCommandProcessor: "agent": self._agent_info(), } + + def _native_voucher_preflight(self, payload: dict[str, Any]) -> dict[str, Any]: + company, company_name = self._resolve_open_company(payload) + engine = NativeVoucherEngine(self.tally) + result = engine.preflight( + company_name, + voucher_type=str(payload.get("voucher_type") or ""), + voucher_date=str(payload.get("voucher_date") or ""), + reference=str(payload.get("reference") or ""), + total_amount=float(payload.get("total_amount") or 0), + lines=list(payload.get("lines") or []), + ) + return { + **result, + "company_name": company_name, + "company_guid": str(company.get("guid") or ""), + "agent": self._agent_info(), + } + + def _post_native_voucher(self, payload: dict[str, Any]) -> dict[str, Any]: + company, company_name = self._resolve_open_company(payload) + engine = NativeVoucherEngine(self.tally) + + preflight = engine.preflight( + company_name, + voucher_type=str(payload.get("voucher_type") or ""), + voucher_date=str(payload.get("voucher_date") or ""), + reference=str(payload.get("reference") or ""), + total_amount=float(payload.get("total_amount") or 0), + lines=list(payload.get("lines") or []), + ) + if preflight.get("duplicate_candidates"): + raise ValueError("Possible duplicate native Tally voucher exists. Posting blocked.") + + result = engine.post( + company_name, + voucher_type=str(payload.get("voucher_type") or ""), + voucher_date=str(payload.get("voucher_date") or ""), + reference=str(payload.get("reference") or ""), + narration=str(payload.get("narration") or ""), + lines=list(payload.get("lines") or []), + ) + safe_result = { + key: value + for key, value in result.items() + if key not in {"raw_response", "request_xml"} + } + self.logger.warning( + "CONTROLLED NATIVE TALLY WRITEBACK posted client_id=%s company=%s " + "erp_voucher_draft_id=%s voucher_type=%s amount=%.2f tally_voucher=%s", + payload.get("client_id"), + company_name, + payload.get("erp_voucher_draft_id"), + str(payload.get("voucher_type") or ""), + float(payload.get("total_amount") or 0), + safe_result.get("last_voucher_id"), + ) + return { + "posted": True, + "company_name": company_name, + "company_guid": str(company.get("guid") or ""), + "voucher_type": str(payload.get("voucher_type") or "").title(), + "reference": str(payload.get("reference") or ""), + "tally_result": safe_result, + "agent": self._agent_info(), + } + def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]: client_id = int(payload.get("client_id")) tally_guid = str(payload.get("tally_guid") or "").strip() diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/native_voucher_engine.py b/app/modules/documents/local_agent_runtime/erp_local_agent/native_voucher_engine.py new file mode 100644 index 0000000..12db42a --- /dev/null +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/native_voucher_engine.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET + + +VALID_VOUCHER_TYPES = {"Purchase", "Sales", "Receipt", "Payment", "Contra", "Journal"} + + +def _tag(element): + return element.tag.split("}")[-1].upper() + + +def _first_text(root, names): + wanted = {str(name).upper() for name in names} + for element in root.iter(): + if _tag(element) in wanted: + text = (element.text or "").strip() + if text: + return text + return "" + + +def _clean_xml_response(text): + value = str(text or "") + start = value.find("<") + return value[start:] if start >= 0 else value + + +def _tally_date(value): + digits = re.sub(r"[^0-9]", "", str(value or "")) + if len(digits) == 8 and str(value).startswith(tuple(str(y) for y in range(1900, 2200))): + return digits + parts = re.split(r"[-/]", str(value or "").strip()) + if len(parts) == 3: + if len(parts[0]) == 4: + return f"{parts[0]}{parts[1].zfill(2)}{parts[2].zfill(2)}" + return f"{parts[2]}{parts[1].zfill(2)}{parts[0].zfill(2)}" + raise ValueError("Voucher date must be a valid YYYY-MM-DD date.") + + +class NativeVoucherEngine: + """Native Tally accounting-voucher writer layered over the existing connector. + + It intentionally reuses the current Tally connector's XML transport, + ledger discovery and voucher export functions so no existing posting + behaviour is replaced. + """ + + def __init__(self, tally_connector): + self.tally = tally_connector + + def _normalize_lines(self, company_name: str, lines: list[dict]): + ledger_rows = self.tally.purchase_posting_ledgers(company_name) + available = { + str(row.get("name") or "").strip().casefold(): row + for row in ledger_rows + if str(row.get("name") or "").strip() + } + + normalized = [] + total_dr = 0.0 + total_cr = 0.0 + + for index, line in enumerate(lines or []): + ledger_name = str(line.get("ledger_name") or "").strip() + side = str(line.get("dr_cr") or "").strip().upper() + amount = round(abs(float(line.get("amount") or 0)), 2) + + if not ledger_name: + raise ValueError(f"Voucher line {index + 1}: ledger is required.") + if ledger_name.casefold() not in available: + raise ValueError( + f"Voucher line {index + 1}: ledger '{ledger_name}' does not exist in the open Tally company." + ) + if side not in {"DR", "CR"}: + raise ValueError(f"Voucher line {index + 1}: DR/CR is invalid.") + 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() + + if side == "DR": + total_dr += amount + else: + total_cr += amount + + normalized.append( + { + "ledger_name": actual_name, + "dr_cr": side, + "amount": amount, + } + ) + + total_dr = round(total_dr, 2) + total_cr = round(total_cr, 2) + + if len(normalized) < 2: + raise ValueError("At least two voucher lines are required.") + if total_dr <= 0 or total_cr <= 0: + raise ValueError("Voucher requires at least one Debit and one Credit.") + if abs(total_dr - total_cr) > 0.01: + raise ValueError( + f"Native voucher is not balanced. Debit={total_dr:.2f}, Credit={total_cr:.2f}." + ) + + return normalized, total_dr, total_cr + + def find_duplicates( + self, + company_name: str, + *, + voucher_type: str, + voucher_date: str, + reference: str, + amount: float, + ): + target_ref = re.sub(r"[^A-Z0-9]", "", str(reference or "").upper()) + expected = round(abs(float(amount or 0)), 2) + matches = [] + + for voucher in self.tally.export_vouchers(company_name, voucher_date, voucher_date): + if str(voucher.get("voucher_type_name") or "").strip().casefold() != str(voucher_type or "").strip().casefold(): + continue + + ref = re.sub(r"[^A-Z0-9]", "", str(voucher.get("reference") or "").upper()) + if target_ref and ref and ref != target_ref: + continue + + values = [ + abs(float(entry.get("amount") or 0)) + for entry in (voucher.get("ledger_entries") or []) + ] + if expected and values and all( + abs(value - expected) > max(1.0, expected * 0.002) + for value in values + ): + continue + + matches.append( + { + "guid": voucher.get("guid") or "", + "voucher_number": voucher.get("voucher_number") or "", + "reference": voucher.get("reference") or "", + "date": voucher.get("date") or "", + } + ) + + return matches[:20] + + def preflight( + self, + company_name: str, + *, + voucher_type: str, + voucher_date: str, + reference: str, + total_amount: float, + lines: list[dict], + ): + 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) + duplicates = self.find_duplicates( + company_name, + voucher_type=vtype, + voucher_date=voucher_date, + reference=reference, + amount=float(total_amount or total_dr), + ) + + return { + "voucher_type": vtype, + "line_count": len(normalized), + "total_debit": total_dr, + "total_credit": total_cr, + "duplicate_candidates": duplicates, + "validated_ledgers": [row["ledger_name"] for row in normalized], + } + + def build_xml( + self, + company_name: str, + *, + voucher_type: str, + voucher_date: str, + reference: str, + narration: str, + lines: list[dict], + ): + vtype = str(voucher_type or "").strip().title() + if vtype not in VALID_VOUCHER_TYPES: + raise ValueError("Unsupported native Tally voucher type.") + + company = str(company_name or "").strip() + if not company: + raise ValueError("Tally company name is required.") + + normalized, total_dr, total_cr = self._normalize_lines(company, lines) + + 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}" + + entries.append( + "" + f"{self.tally._xml_escape(row['ledger_name'])}" + f"{deemed_positive}" + f"{signed_amount}" + "" + ) + + ref_xml = ( + f"{self.tally._xml_escape(reference)}" + if str(reference or "").strip() + else "" + ) + + return f""" +
+ 1 + Import + Data + Vouchers +
+ + + + {self.tally._xml_escape(company)} + + + + + + {_tally_date(voucher_date)} + {vtype} + Accounting Voucher View + {ref_xml} + {self.tally._xml_escape(narration)} + {''.join(entries)} + + + + +
""" + + def post( + self, + company_name: str, + *, + voucher_type: str, + voucher_date: str, + reference: str, + narration: str, + lines: list[dict], + ): + request_xml = self.build_xml( + company_name, + voucher_type=voucher_type, + voucher_date=voucher_date, + reference=reference, + narration=narration, + lines=lines, + ) + response_xml = self.tally._post_xml(request_xml) + root = ET.fromstring(_clean_xml_response(response_xml).encode("utf-8")) + + def _int(tag): + try: + return int(float(_first_text(root, [tag]) or 0)) + except Exception: + return 0 + + errors = _int("ERRORS") + line_error = _first_text(root, ["LINEERROR"]) + created = _int("CREATED") + altered = _int("ALTERED") + + if errors or line_error: + raise ValueError( + line_error or f"Tally reported {errors} native voucher error(s)." + ) + if created < 1 and altered < 1: + raise ValueError("Tally did not confirm creation of the native voucher.") + + return { + "created": created, + "altered": altered, + "errors": errors, + "last_voucher_id": str( + _first_text(root, ["LASTVCHID", "LASTMID", "LASTVOUCHERID"]) or "" + ), + "raw_response": response_xml, + "request_xml": request_xml, + } diff --git a/app/ui/app.py b/app/ui/app.py index 5a4d1cd..47e9d0e 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -51,6 +51,7 @@ from app.modules.accounting.sales_ui import router as accounting_sales_income_ui from app.modules.accounting.sales_learning_ui import router as accounting_sales_learning_ui_router 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.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 @@ -88,6 +89,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(accounting_sales_learning_ui_router) 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(work_tracker_ui_router) app.include_router(billing_ui_router) app.include_router(platform_billing_ui_router)