Add Phase 17 common native Tally voucher engine

This commit is contained in:
A R R R Associates
2026-08-23 15:26:37 +05:30
parent 7d11236eed
commit 1111a52917
11 changed files with 1809 additions and 3 deletions
@@ -20,6 +20,7 @@
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/chart-of-accounts{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl bg-blue-700 px-3 py-2 text-sm font-semibold text-white">Chart of Accounts - Groups & Ledgers</a>
<a href="/tools/accounting/voucher-engine{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-blue-300 bg-white px-3 py-2 text-sm font-semibold text-blue-800">Accounting Voucher Engine</a>
</div>
</div>
@@ -0,0 +1,339 @@
{% 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 17</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Common Native Tally Voucher Engine</h1>
<p class="mt-1 max-w-5xl text-sm text-slate-500">
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.
</p>
</div>
<div class="flex flex-wrap gap-2">
<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>
</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 lg:grid-cols-[1fr_1fr_auto]">
<label class="text-sm font-semibold">ERP Client
<select name="client_id" required class="mt-1 w-full rounded-xl 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-sm font-semibold">Tally Company / Chart of Accounts
<select name="tally_guid" class="mt-1 w-full rounded-xl 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>
<div class="flex items-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Open Engine</button></div>
</form>
</section>
{% if selected_client and selected_tally_guid %}
<section class="rounded-2xl border border-blue-200 bg-blue-50/40 p-5">
<h2 class="font-semibold text-slate-900">Native Tally voucher selection</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 text-sm">
<div class="rounded-xl bg-white p-3"><b>Purchase</b><br><span class="text-slate-500">Supplier bills, purchases, direct/indirect expenses and asset bills.</span></div>
<div class="rounded-xl bg-white p-3"><b>Sales</b><br><span class="text-slate-500">Customer invoices, sales and billed direct/indirect income.</span></div>
<div class="rounded-xl bg-white p-3"><b>Receipt</b><br><span class="text-slate-500">Money received into Bank/Cash from customers, income, loans or capital.</span></div>
<div class="rounded-xl bg-white p-3"><b>Payment</b><br><span class="text-slate-500">Money paid from Bank/Cash to suppliers, expenses, assets or loans.</span></div>
<div class="rounded-xl bg-white p-3"><b>Contra</b><br><span class="text-slate-500">Bank-to-bank, bank-to-cash and cash-to-bank transfers.</span></div>
<div class="rounded-xl bg-white p-3"><b>Journal</b><br><span class="text-slate-500">Accruals, provisions, depreciation, reclassification and adjustments.</span></div>
</div>
</section>
<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>
</div>
<form method="post" action="/tools/accounting/voucher-engine/draft" id="voucherDraftForm" class="space-y-5">
<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 }}">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<label class="text-xs font-semibold">Business Nature
<select name="business_nature" id="businessNature" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
{% for code,label in business_natures %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Tally Voucher Type
<select name="voucher_type" id="voucherType" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
{% for v in voucher_types %}<option value="{{ v }}">{{ v }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Voucher Date
<input type="date" name="voucher_date" required class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
</label>
<label class="text-xs font-semibold">Reference
<input name="reference" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
</label>
<label class="text-xs font-semibold">Source
<select name="source_type" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="manual">Manual</option>
<option value="bank">Bank Analyzer</option>
<option value="gstr2b">GSTR-2B</option>
<option value="sales">Sales / E-Invoice / GSTR-1</option>
<option value="audit_adjustment">Audit Adjustment</option>
</select>
</label>
</div>
<label class="block text-xs font-semibold">Narration
<textarea name="narration" rows="2" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"></textarea>
</label>
<div>
<div class="mb-2 flex items-center justify-between">
<div>
<h3 class="font-semibold">Voucher Lines</h3>
<p class="text-xs text-slate-500">Add as many balanced Debit/Credit lines as needed.</p>
</div>
<button type="button" id="addLine" class="rounded-lg border border-slate-300 px-3 py-2 text-xs font-semibold">Add Line</button>
</div>
<div id="lineRows" class="space-y-2">
{% for index in range(2) %}
<div class="voucher-line grid gap-2 rounded-xl border border-slate-200 p-3 lg:grid-cols-[1fr_100px_160px_1fr_auto]">
<select name="ledger_name" required class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Select ledger</option>
{% for ledger in ledgers %}
{% set role = ledger.manual_role_code or ledger.suggested_role_code %}
<option value="{{ ledger.name }}">{{ ledger.name }} · {{ role_labels.get(role, role) }}</option>
{% endfor %}
</select>
<select name="dr_cr" required class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="DR" {% if index==0 %}selected{% endif %}>Debit</option>
<option value="CR" {% if index==1 %}selected{% endif %}>Credit</option>
</select>
<input name="amount" type="number" min="0.01" step="0.01" required placeholder="Amount" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<input name="line_note" placeholder="Line note (optional)" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<button type="button" class="remove-line rounded-lg border border-red-200 px-3 py-2 text-xs font-semibold text-red-700">Remove</button>
</div>
{% endfor %}
</div>
<div class="mt-3 flex flex-wrap justify-end gap-5 text-sm font-semibold">
<span>Debit: ₹<span id="debitTotal">0.00</span></span>
<span>Credit: ₹<span id="creditTotal">0.00</span></span>
<span id="balanceState" class="text-amber-700">Not balanced</span>
</div>
</div>
<button class="w-full rounded-xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white">Create Balanced Voucher Draft</button>
</form>
</section>
{% elif selected_client %}
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
Refresh the client's Chart of Accounts and select a mapped Tally company before creating vouchers.
</div>
{% endif %}
{% if selected_client %}
<section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="font-semibold">Voucher Review & Posting Queue</h2>
<p class="text-sm text-slate-500">{{ total }} draft(s). Controlled flow: Draft → Reviewed → Approved → Tally Preflight → Post.</p>
</div>
<form method="get" class="flex gap-2">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<select name="status" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">All statuses</option>
{% for s in ['draft','reviewed','approved','preflight_queued','preflight_claimed','preflight_ready','preflight_failed','posting_queued','posting_claimed','posted','posting_failed','void'] %}
<option value="{{ s }}" {% if status_filter==s %}selected{% endif %}>{{ s|replace('_',' ')|title }}</option>
{% endfor %}
</select>
<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Filter</button>
</form>
</div>
</div>
<div class="divide-y divide-slate-100">
{% for draft in drafts %}
<article class="p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-slate-900 px-2 py-1 text-xs font-semibold text-white">{{ draft.voucher_type }}</span>
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ draft.status|replace('_',' ')|title }}</span>
<span class="text-xs text-slate-500">Draft #{{ draft.id }}</span>
</div>
<div class="mt-2 font-semibold">{{ draft.company_name }} · {{ draft.voucher_date }}{% if draft.reference %} · Ref {{ draft.reference }}{% endif %}</div>
<div class="mt-1 text-sm text-slate-500">Nature: {{ draft.business_nature|replace('_',' ')|title }} · Source: {{ draft.source_type }} · Audit use: {{ draft.audit_area or 'General Ledger' }}</div>
{% if draft.narration %}<div class="mt-1 text-sm text-slate-500">{{ draft.narration }}</div>{% endif %}
</div>
<div class="text-right">
<div class="text-lg font-semibold">₹{{ '%.2f'|format(draft.total_debit) }}</div>
{% if draft.tally_voucher_number %}<div class="text-xs text-emerald-700">Tally voucher {{ draft.tally_voucher_number }}</div>{% endif %}
</div>
</div>
<div class="mt-4 overflow-x-auto rounded-xl border border-slate-200">
<table class="min-w-full text-sm">
<thead><tr class="bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-3 py-2">Ledger</th><th class="px-3 py-2">Role</th><th class="px-3 py-2">Dr/Cr</th><th class="px-3 py-2 text-right">Amount</th></tr></thead>
<tbody>
{% for line in draft.lines %}
<tr class="border-t border-slate-100">
<td class="px-3 py-2 font-semibold">{{ line.ledger_name }}</td>
<td class="px-3 py-2">{{ role_labels.get(line.ledger_role_code, line.ledger_role_code) }}</td>
<td class="px-3 py-2">{{ line.dr_cr }}</td>
<td class="px-3 py-2 text-right">₹{{ '%.2f'|format(line.amount) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% 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 %}
<div class="mt-4 flex flex-wrap gap-2">
{% if draft.status == 'draft' %}
<form method="post" action="/tools/accounting/voucher-engine/draft/{{ draft.id }}/review" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input name="note" placeholder="Review note" class="rounded-lg border border-slate-300 px-3 py-2 text-xs">
<button class="rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white">Mark Reviewed</button>
</form>
{% endif %}
{% if draft.status == 'reviewed' %}
<form method="post" action="/tools/accounting/voucher-engine/draft/{{ draft.id }}/approve">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<button class="rounded-lg bg-emerald-700 px-3 py-2 text-xs font-semibold text-white">Approve</button>
</form>
{% endif %}
{% if draft.status in ['approved','preflight_failed'] %}
<form method="post" action="/tools/accounting/voucher-engine/draft/{{ draft.id }}/preflight" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<select name="workstation_id" required class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs">
<option value="">Select workstation</option>
{% for ws in workstations %}<option value="{{ ws.id }}">{{ ws.machine_name }} · Agent {{ ws.agent_version or '-' }}</option>{% endfor %}
</select>
<button class="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-white">Run Tally Preflight</button>
</form>
{% endif %}
{% if draft.status == 'preflight_ready' %}
<form method="post" action="/tools/accounting/voucher-engine/draft/{{ draft.id }}/post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<button class="rounded-lg bg-red-700 px-3 py-2 text-xs font-semibold text-white" onclick="return confirm('Post this approved {{ draft.voucher_type }} voucher to Tally?')">Post {{ draft.voucher_type }} to Tally</button>
</form>
{% endif %}
{% if draft.status not in ['posted','void','preflight_queued','preflight_claimed','posting_queued','posting_claimed'] %}
<form method="post" action="/tools/accounting/voucher-engine/draft/{{ draft.id }}/void">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<button class="rounded-lg border border-slate-300 px-3 py-2 text-xs font-semibold">Void Draft</button>
</form>
{% endif %}
</div>
</article>
{% else %}
<div class="p-10 text-center text-slate-500">No voucher drafts for this client yet.</div>
{% endfor %}
</div>
{% if pages > 1 %}
<div class="flex items-center justify-between border-t border-slate-200 p-4 text-sm">
<span>Page {{ page }} of {{ pages }}</span>
<div class="flex gap-2">
{% if page > 1 %}<a class="rounded-lg border border-slate-300 px-3 py-1" href="?client_id={{ selected_client.id }}&status={{ status_filter }}&page={{ page-1 }}&per_page={{ per_page }}">Previous</a>{% endif %}
{% if page < pages %}<a class="rounded-lg border border-slate-300 px-3 py-1" href="?client_id={{ selected_client.id }}&status={{ status_filter }}&page={{ page+1 }}&per_page={{ per_page }}">Next</a>{% endif %}
</div>
</div>
{% endif %}
</section>
{% endif %}
</div>
{% if selected_client and selected_tally_guid %}
<script>
(function () {
const container = document.getElementById("lineRows");
const addButton = document.getElementById("addLine");
const nature = document.getElementById("businessNature");
const voucherType = document.getElementById("voucherType");
const debitTotal = document.getElementById("debitTotal");
const creditTotal = document.getElementById("creditTotal");
const balanceState = document.getElementById("balanceState");
const voucherMap = {
PURCHASE: "Purchase",
DIRECT_EXPENSE: "Purchase",
INDIRECT_EXPENSE: "Purchase",
SALES: "Sales",
DIRECT_INCOME: "Sales",
INDIRECT_INCOME: "Sales",
RECEIPT: "Receipt",
PAYMENT: "Payment",
CONTRA: "Contra",
ADJUSTMENT: "Journal"
};
function updateTotals() {
let dr = 0, cr = 0;
container.querySelectorAll(".voucher-line").forEach(function (row) {
const side = row.querySelector('[name="dr_cr"]').value;
const amount = Number(row.querySelector('[name="amount"]').value || 0);
if (side === "DR") dr += amount; else if (side === "CR") cr += amount;
});
debitTotal.textContent = dr.toFixed(2);
creditTotal.textContent = cr.toFixed(2);
const ok = dr > 0 && cr > 0 && Math.abs(dr - cr) < 0.01;
balanceState.textContent = ok ? "Balanced" : "Not balanced";
balanceState.className = ok ? "text-emerald-700" : "text-amber-700";
}
function wire(row) {
row.querySelectorAll("input,select").forEach(function (el) {
el.addEventListener("input", updateTotals);
el.addEventListener("change", updateTotals);
});
row.querySelector(".remove-line").addEventListener("click", function () {
if (container.querySelectorAll(".voucher-line").length <= 2) return;
row.remove();
updateTotals();
});
}
container.querySelectorAll(".voucher-line").forEach(wire);
addButton.addEventListener("click", function () {
const source = container.querySelector(".voucher-line");
const clone = source.cloneNode(true);
clone.querySelector('[name="ledger_name"]').value = "";
clone.querySelector('[name="dr_cr"]').value = "DR";
clone.querySelector('[name="amount"]').value = "";
clone.querySelector('[name="line_note"]').value = "";
container.appendChild(clone);
wire(clone);
updateTotals();
});
nature.addEventListener("change", function () {
voucherType.value = voucherMap[nature.value] || "Journal";
});
updateTotals();
})();
</script>
{% endif %}
{% endblock %}
@@ -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")
@@ -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)
+355
View File
@@ -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()
+1 -1
View File
@@ -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)
@@ -1,2 +1,2 @@
__version__ = "1.16.0"
__version__ = "1.17.0"
AGENT_NAME = "ERP Local Agent"
@@ -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()
@@ -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(
"<ALLLEDGERENTRIES.LIST>"
f"<LEDGERNAME>{self.tally._xml_escape(row['ledger_name'])}</LEDGERNAME>"
f"<ISDEEMEDPOSITIVE>{deemed_positive}</ISDEEMEDPOSITIVE>"
f"<AMOUNT>{signed_amount}</AMOUNT>"
"</ALLLEDGERENTRIES.LIST>"
)
ref_xml = (
f"<REFERENCE>{self.tally._xml_escape(reference)}</REFERENCE>"
if str(reference or "").strip()
else ""
)
return f"""<ENVELOPE>
<HEADER>
<VERSION>1</VERSION>
<TALLYREQUEST>Import</TALLYREQUEST>
<TYPE>Data</TYPE>
<ID>Vouchers</ID>
</HEADER>
<BODY>
<DESC>
<STATICVARIABLES>
<SVCURRENTCOMPANY>{self.tally._xml_escape(company)}</SVCURRENTCOMPANY>
</STATICVARIABLES>
</DESC>
<DATA>
<TALLYMESSAGE xmlns:UDF="TallyUDF">
<VOUCHER VCHTYPE="{vtype}" ACTION="Create" OBJVIEW="Accounting Voucher View">
<DATE>{_tally_date(voucher_date)}</DATE>
<VOUCHERTYPENAME>{vtype}</VOUCHERTYPENAME>
<PERSISTEDVIEW>Accounting Voucher View</PERSISTEDVIEW>
{ref_xml}
<NARRATION>{self.tally._xml_escape(narration)}</NARRATION>
{''.join(entries)}
</VOUCHER>
</TALLYMESSAGE>
</DATA>
</BODY>
</ENVELOPE>"""
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,
}