Add Phase 6 controlled Tally writeback

This commit is contained in:
A R R R Associates
2026-08-19 22:33:15 +05:30
parent 66d286fd55
commit 193fd4b71c
11 changed files with 464 additions and 21 deletions
+23 -1
View File
@@ -8,7 +8,7 @@ import os
import sqlite3
from typing import Iterator, Sequence
ACT_SCHEMA_VERSION = 5
ACT_SCHEMA_VERSION = 6
class AccountingActStoreError(RuntimeError):
@@ -222,6 +222,19 @@ class AccountingActStore:
CREATE INDEX IF NOT EXISTS ix_it_dep_runs_company_fy ON it_depreciation_runs(tally_guid, fy_start, fy_end, id);
CREATE INDEX IF NOT EXISTS ix_it_dep_lines_run ON it_depreciation_run_lines(run_id, line_no);
CREATE TABLE IF NOT EXISTS tally_writeback_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT, depreciation_run_id INTEGER NOT NULL,
tally_guid TEXT NOT NULL, company_name TEXT NOT NULL, voucher_type TEXT NOT NULL DEFAULT 'Journal',
voucher_date TEXT NOT NULL, reference TEXT NOT NULL DEFAULT '', debit_ledger TEXT NOT NULL,
credit_ledger TEXT NOT NULL, amount REAL NOT NULL DEFAULT 0, requested_by_user_id INTEGER,
started_at_utc TEXT NOT NULL, completed_at_utc TEXT, status TEXT NOT NULL,
tally_created INTEGER NOT NULL DEFAULT 0, tally_altered INTEGER NOT NULL DEFAULT 0,
tally_errors INTEGER NOT NULL DEFAULT 0, tally_last_voucher_id TEXT NOT NULL DEFAULT '',
request_xml TEXT NOT NULL DEFAULT '', response_xml TEXT NOT NULL DEFAULT '', error_message TEXT,
FOREIGN KEY(depreciation_run_id) REFERENCES it_depreciation_runs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_tally_writeback_run ON tally_writeback_attempts(depreciation_run_id, id);
CREATE INDEX IF NOT EXISTS ix_tally_groups_company ON tally_groups(tally_guid, name);
CREATE INDEX IF NOT EXISTS ix_tally_ledgers_company ON tally_ledgers(tally_guid, name);
CREATE INDEX IF NOT EXISTS ix_tally_voucher_types_company ON tally_voucher_types(tally_guid, name);
@@ -245,6 +258,15 @@ class AccountingActStore:
"""
)
self._ensure_mapping_columns(db)
dep_columns = {row["name"] for row in db.execute("PRAGMA table_info(it_depreciation_runs)").fetchall()}
for name, ddl in {
"approved_by_user_id": "INTEGER", "approved_at_utc": "TEXT", "approval_note": "TEXT NOT NULL DEFAULT ''",
"posted_by_user_id": "INTEGER", "posted_at_utc": "TEXT", "posted_voucher_type": "TEXT NOT NULL DEFAULT ''",
"posted_voucher_number": "TEXT NOT NULL DEFAULT ''", "tally_posting_status": "TEXT NOT NULL DEFAULT ''",
"tally_posting_error": "TEXT NOT NULL DEFAULT ''",
}.items():
if name not in dep_columns:
db.execute(f"ALTER TABLE it_depreciation_runs ADD COLUMN {name} {ddl}")
now = _utc_now_iso()
metadata = {
"schema_version": str(ACT_SCHEMA_VERSION),
@@ -6,20 +6,22 @@
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Tally · Phase 5</p>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Tally · Phase 6</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Depreciation (Income Tax)</h1>
<p class="mt-1 text-sm text-slate-500">Review synchronized Fixed Assets, assign rates ledger-wise and generate a draft depreciation calculation. No entry is posted to Tally.</p>
<p class="mt-1 text-sm text-slate-500">Calculate Income-tax depreciation, approve the reviewed draft and—only after explicit confirmation—post the approved Journal to the mapped company currently open in TallyPrime.</p>
</div>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Back to Tally</a>
</div>
{% if calculated %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">Income-tax depreciation draft calculated and saved in the client accounting database. Nothing was posted to Tally.</div>{% endif %}
{% if calculated %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">Income-tax depreciation draft calculated and saved. It still requires explicit Partner approval before Tally write-back.</div>{% endif %}
{% if approved %}<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900">Depreciation draft approved. No Tally voucher has been created yet.</div>{% endif %}
{% if posted %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">Approved depreciation Journal posted to TallyPrime successfully.</div>{% endif %}
{% if command_error %}<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ command_error }}</div>{% endif %}
<section class="grid gap-4 md:grid-cols-3">
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">ERP Local Agent</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if agent_online else 'text-red-700' }}">{{ 'Connected' if agent_online else 'Offline' }}</div><div class="mt-1 text-xs text-slate-500">Agent {{ agent.version if agent and agent.version else '-' }}</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">TallyPrime</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if tally and tally.connected else 'text-slate-700' }}">{{ 'Connected' if tally and tally.connected else 'Not connected' }}</div><div class="mt-1 text-xs text-slate-500">{{ tally.company_count if tally else 0 }} currently loaded</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Phase 5</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if agent and agent.it_depreciation_capability else 'text-slate-700' }}">{{ 'Available' if agent and agent.it_depreciation_capability else 'Update Agent' }}</div><div class="mt-1 text-xs text-slate-500">Draft calculation only</div></div>
<div class="rounded-2xl bg-white p-5 shadow-soft"><div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Phase 6</div><div class="mt-2 text-lg font-semibold {{ 'text-emerald-700' if agent and agent.tally_writeback_capability else 'text-slate-700' }}">{{ 'Available' if agent and agent.tally_writeback_capability else 'Update Agent' }}</div><div class="mt-1 text-xs text-slate-500">Controlled approved write-back</div></div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
@@ -44,7 +46,7 @@
<div><label class="text-sm font-medium text-slate-700">Depreciation Expense Ledger</label><select name="depreciation_expense_ledger" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">Depreciation</option>{% for name in preview.all_ledgers %}<option value="{{ name }}">{{ name }}</option>{% endfor %}</select></div>
<div><label class="text-sm font-medium text-slate-700">Depreciation Reserve / Credit Ledger</label><select name="depreciation_reserve_ledger" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2"><option value="">Depreciation Reserve</option>{% for name in preview.all_ledgers %}<option value="{{ name }}">{{ name }}</option>{% endfor %}</select></div>
</div>
<p class="mt-3 text-xs text-slate-500">These ledger selections are stored with the draft for the proposed journal. Phase 5 does not create or post any Tally voucher.</p>
<p class="mt-3 text-xs text-slate-500">These ledger selections are stored with the draft. Phase 6 can post the Journal only after the saved draft is explicitly approved.</p>
</section>
<section class="overflow-hidden rounded-2xl bg-white shadow-soft">
@@ -59,11 +61,43 @@
{% if depreciation_run %}
<section class="rounded-2xl bg-white p-5 shadow-soft"><div class="flex flex-wrap justify-between gap-4"><div><h2 class="font-semibold text-slate-900">Draft Calculation #{{ depreciation_run.id }}</h2><p class="mt-1 text-sm text-slate-500">{{ depreciation_run.company_name }} · {{ depreciation_run.fy_start }} to {{ depreciation_run.fy_end }}</p></div><div class="text-right"><div class="text-xs uppercase tracking-wide text-slate-500">Total Depreciation</div><div class="text-2xl font-semibold text-slate-900">₹ {{ '%.2f'|format(depreciation_run.total_depreciation) }}</div></div></div>
<div class="mt-4 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Proposed only: Debit {{ depreciation_run.depreciation_expense_ledger or 'Depreciation' }} and credit {{ depreciation_run.depreciation_reserve_ledger or 'Depreciation Reserve' }} for ₹ {{ '%.2f'|format(depreciation_run.total_depreciation) }}. Tally write-back remains disabled.</div>
<div class="mt-4 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Journal: Debit <strong>{{ depreciation_run.depreciation_expense_ledger or 'Depreciation' }}</strong> and credit <strong>{{ depreciation_run.depreciation_reserve_ledger or 'Depreciation Reserve' }}</strong> for ₹ {{ '%.2f'|format(depreciation_run.total_depreciation) }}. Voucher date will be {{ depreciation_run.fy_end }} and reference ARRR-ITDEP-{{ depreciation_run.id }}.</div>
<div class="mt-4 grid gap-3 md:grid-cols-3">
<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs uppercase tracking-wide text-slate-500">Draft Status</div><div class="mt-1 font-semibold text-slate-900">{{ depreciation_run.status|upper }}</div></div>
<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs uppercase tracking-wide text-slate-500">Approved</div><div class="mt-1 font-semibold text-slate-900">{{ depreciation_run.approved_at_utc or 'Not yet approved' }}</div></div>
<div class="rounded-xl border border-slate-200 p-3"><div class="text-xs uppercase tracking-wide text-slate-500">Tally Posting</div><div class="mt-1 font-semibold {{ 'text-emerald-700' if depreciation_run.tally_posting_status == 'posted' else ('text-red-700' if depreciation_run.tally_posting_status == 'failed' else 'text-slate-900') }}">{{ depreciation_run.tally_posting_status|upper if depreciation_run.tally_posting_status else 'NOT POSTED' }}</div></div>
</div>
{% if depreciation_run.tally_posting_error %}<div class="mt-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{{ depreciation_run.tally_posting_error }}</div>{% endif %}
<div class="mt-4 overflow-x-auto"><table class="min-w-full text-sm"><thead class="bg-slate-50 text-left text-xs uppercase text-slate-500"><tr><th class="px-3 py-2">Ledger</th><th class="px-3 py-2 text-right">Rate</th><th class="px-3 py-2 text-right">Depreciation</th><th class="px-3 py-2 text-right">Closing WDV</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in depreciation_run.lines %}<tr><td class="px-3 py-2">{{ row.ledger_name }}</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.rate_percent) }}%</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.depreciation_amount) }}</td><td class="px-3 py-2 text-right">{{ '%.2f'|format(row.closing_wdv) }}</td></tr>{% endfor %}</tbody></table></div>
{% if depreciation_run.status == 'draft' %}
<form method="post" action="/tools/tally/depreciation/approve" class="mt-5 rounded-xl border border-amber-200 bg-amber-50 p-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="run_id" value="{{ depreciation_run.id }}">
<div class="font-semibold text-amber-900">Partner Approval Required</div>
<p class="mt-1 text-sm text-amber-800">Review the company, financial year, ledgers, rates and total before approving. Approval does not post anything to Tally.</p>
<textarea name="approval_note" rows="2" class="mt-3 w-full rounded-xl border border-amber-300 px-3 py-2" placeholder="Approval note (optional)"></textarea>
<label class="mt-3 flex items-start gap-2 text-sm text-amber-900"><input type="checkbox" name="confirm_approval" value="yes" class="mt-1"><span>I have reviewed this depreciation draft and approve it for Tally posting.</span></label>
<button type="submit" class="mt-3 rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-800">Approve Draft</button>
</form>
{% elif depreciation_run.status == 'approved' %}
<form method="post" action="/tools/tally/depreciation/post-to-tally" class="mt-5 rounded-xl border border-red-200 bg-red-50 p-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}"><input type="hidden" name="run_id" value="{{ depreciation_run.id }}">
<div class="font-semibold text-red-900">Controlled Tally Write-back</div>
<p class="mt-1 text-sm text-red-800">This creates a real Journal voucher in the mapped company currently open in TallyPrime. The Local Agent re-checks the Tally GUID immediately before posting. Successful drafts cannot be posted twice.</p>
<label class="mt-3 flex items-start gap-2 text-sm text-red-900"><input type="checkbox" name="confirm_posting" value="yes" class="mt-1"><span>Create this Journal voucher in TallyPrime.</span></label>
<div class="mt-3 max-w-xs"><label class="text-sm font-medium text-red-900">Type POST to confirm</label><input name="post_confirmation" autocomplete="off" class="mt-1 w-full rounded-xl border border-red-300 px-3 py-2" placeholder="POST"></div>
<button type="submit" class="mt-3 rounded-xl bg-red-700 px-4 py-2 text-sm font-semibold text-white hover:bg-red-800">Post Approved Journal to Tally</button>
</form>
{% elif depreciation_run.status == 'posted' %}
<div class="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800"><strong>Posted successfully.</strong> Posted at {{ depreciation_run.posted_at_utc }}{% if depreciation_run.posted_voucher_number %}; Tally voucher/reference ID {{ depreciation_run.posted_voucher_number }}{% endif %}. This draft is locked against duplicate posting.</div>
{% endif %}
{% if depreciation_run.writeback_attempts %}
<div class="mt-5"><h3 class="text-sm font-semibold text-slate-900">Write-back Audit Trail</h3><div class="mt-2 overflow-x-auto"><table class="min-w-full text-xs"><thead class="bg-slate-50 text-left uppercase text-slate-500"><tr><th class="px-3 py-2">Attempt</th><th class="px-3 py-2">Started</th><th class="px-3 py-2">Status</th><th class="px-3 py-2">Tally result</th><th class="px-3 py-2">Error</th></tr></thead><tbody class="divide-y divide-slate-100">{% for row in depreciation_run.writeback_attempts %}<tr><td class="px-3 py-2">#{{ row.id }}</td><td class="px-3 py-2">{{ row.started_at_utc }}</td><td class="px-3 py-2">{{ row.status|upper }}</td><td class="px-3 py-2">Created {{ row.tally_created }} · Errors {{ row.tally_errors }}{% if row.tally_last_voucher_id %} · ID {{ row.tally_last_voucher_id }}{% endif %}</td><td class="px-3 py-2 text-red-700">{{ row.error_message or '' }}</td></tr>{% endfor %}</tbody></table></div></div>
{% endif %}
</section>
{% endif %}
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Phase 5 is review/calculation only. Phase 6 will add controlled, approved Tally write-back.</div>
<div class="rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">Phase 6 enables only controlled depreciation Journal write-back. All master and transaction synchronization remain read-only; no other Tally create/alter/delete operation is enabled.</div>
</div>
{% endblock %}
+76 -2
View File
@@ -478,6 +478,8 @@ def depreciation_it_tool(
fy_start: str = "",
fy_end: str = "",
calculated: int = 0,
approved: int = 0,
posted: int = 0,
run_id: int | None = None,
error: str = "",
):
@@ -494,7 +496,7 @@ def depreciation_it_tool(
live_result = None; preview = None; depreciation_run = None; command_error = error or ""
if selected_client and node and online:
try:
status_response = request_agent_command(node.node_code,"phase5_status",{"client_id":int(selected_client.id)},timeout_seconds=20)
status_response = request_agent_command(node.node_code,"phase6_status",{"client_id":int(selected_client.id)},timeout_seconds=20)
if status_response.get("ok"): live_result=status_response.get("result") or {}
else: command_error=str(status_response.get("error") or "Local Agent status failed.")
chosen_guid=str(tally_guid or "").strip()
@@ -507,7 +509,7 @@ def depreciation_it_tool(
if run_response.get("ok"): depreciation_run=(run_response.get("result") or {}).get("depreciation")
except Exception as exc: command_error=str(exc)
base={"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)}
base.update({"title":"Income-tax Depreciation","clients":clients,"selected_client":selected_client,"storage_node":node,"agent_online":online,"live_result":live_result,"preview":preview,"selected_tally_guid":str(tally_guid or ""),"fy_start":start_text,"fy_end":end_text,"calculated":bool(calculated),"depreciation_run":depreciation_run,"command_error":command_error})
base.update({"title":"Income-tax Depreciation","clients":clients,"selected_client":selected_client,"storage_node":node,"agent_online":online,"live_result":live_result,"preview":preview,"selected_tally_guid":str(tally_guid or ""),"fy_start":start_text,"fy_end":end_text,"calculated":bool(calculated),"approved":bool(approved),"posted":bool(posted),"depreciation_run":depreciation_run,"command_error":command_error})
return templates.TemplateResponse("modules/accounting/templates/accounting/depreciation_it.html",base)
finally: db.close()
@@ -543,3 +545,75 @@ async def calculate_it_depreciation(request: Request):
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&calculated=1&run_id={rid}",status_code=303)
finally: db.close()
@router.post("/depreciation/approve")
async def approve_it_depreciation(request: Request):
form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or ""))
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.writeback")
if response: return response
try:
client_id = int(form.get("client_id")); run_id = int(form.get("run_id"))
except Exception:
return RedirectResponse(url="/tools/tally/depreciation?error=" + quote("Invalid depreciation draft."), status_code=303)
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client: return _denied()
if str(form.get("confirm_approval") or "") != "yes":
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('Confirm that you reviewed the depreciation draft before approval.')}", status_code=303)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303)
try:
result = request_agent_command(
node.node_code, "accounting_approve_it_depreciation",
{"client_id": client_id, "run_id": run_id, "approved_by_user_id": int(user.id), "approval_note": str(form.get("approval_note") or "")},
timeout_seconds=30,
)
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Approval failed."))
dep = (result.get("result") or {}).get("depreciation") or {}
guid = quote(str(dep.get("tally_guid") or ""))
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&tally_guid={guid}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&approved=1", status_code=303)
except Exception as exc:
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote(str(exc))}", status_code=303)
finally:
db.close()
@router.post("/depreciation/post-to-tally")
async def post_it_depreciation_to_tally(request: Request):
form = await request.form()
validate_csrf(request, str(form.get("csrf_token") or ""))
db = CommonSessionLocal()
try:
user, response = _require_partner(request, db, "accounting.tally.writeback")
if response: return response
try:
client_id = int(form.get("client_id")); run_id = int(form.get("run_id"))
except Exception:
return RedirectResponse(url="/tools/tally/depreciation?error=" + quote("Invalid depreciation draft."), status_code=303)
client, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client: return _denied()
if str(form.get("post_confirmation") or "").strip().upper() != "POST":
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('Type POST exactly to confirm Tally write-back.')}", status_code=303)
if str(form.get("confirm_posting") or "") != "yes":
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('Confirm that you want to create the Journal voucher in TallyPrime.')}", status_code=303)
node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
if not node or not _node_online(node):
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote('ERP Local Agent is offline for the active branch.')}", status_code=303)
try:
result = request_agent_command(
node.node_code, "accounting_post_it_depreciation",
{"client_id": client_id, "run_id": run_id, "posted_by_user_id": int(user.id)},
timeout_seconds=120,
)
if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Tally write-back failed."))
dep = (result.get("result") or {}).get("depreciation") or {}
guid = quote(str(dep.get("tally_guid") or ""))
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&tally_guid={guid}&fy_start={dep.get('fy_start','')}&fy_end={dep.get('fy_end','')}&run_id={run_id}&posted=1", status_code=303)
except Exception as exc:
return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client_id}&run_id={run_id}&error={quote(str(exc))}", status_code=303)
finally:
db.close()