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 %}
|
||||
Reference in New Issue
Block a user