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()