Add Phase 17 common native Tally voucher engine
This commit is contained in:
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user