Add Phase 6 controlled Tally writeback
This commit is contained in:
@@ -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 %}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -153,6 +153,7 @@ PERMISSIONS = {
|
||||
"accounting.tally.sync_masters": "Synchronize Tally Accounting Masters",
|
||||
"accounting.tally.sync_transactions": "Synchronize Tally Accounting Transactions",
|
||||
"accounting.tally.depreciation_it": "Calculate Income-tax Depreciation",
|
||||
"accounting.tally.writeback": "Approve and Post Accounting Entries to Tally",
|
||||
"accounting.act.initialize": "Initialize Client Accounting ACT Storage",
|
||||
|
||||
"notice_cases.view": "View Notice and Case Management",
|
||||
@@ -185,3 +186,4 @@ def expand_permission_codes(code: str) -> list[str]:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ERP_LOCAL_AGENT_VERSION = "1.6.0"
|
||||
ERP_LOCAL_AGENT_VERSION = "1.7.0"
|
||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ERP Local Agent 1.4.0
|
||||
ERP Local Agent 1.7.0
|
||||
|
||||
Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved.
|
||||
|
||||
@@ -24,3 +24,5 @@ Phase 3: read-only Tally accounting master sync (Groups, Ledgers, Voucher Types,
|
||||
Version 1.6.0: Phase 4 read-only Tally transaction synchronization (vouchers, ledger/inventory entries, bill/cost-centre/bank allocations) with raw voucher XML preservation.
|
||||
|
||||
Phase 5 adds draft-only Income-tax depreciation calculations from synchronized .act data. No Tally write-back.
|
||||
|
||||
Phase 6: approved Income-tax depreciation Journal write-back is available only after explicit Partner approval and POST confirmation. All other Tally operations remain read-only.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.6.0"
|
||||
__version__ = "1.7.0"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -7,7 +7,7 @@ import sqlite3
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
SCHEMA_VERSION = "5"
|
||||
SCHEMA_VERSION = "6"
|
||||
|
||||
MASTER_TABLES = {
|
||||
"groups": "tally_groups",
|
||||
@@ -32,8 +32,8 @@ class LocalAccountingStore:
|
||||
Phase 3 preserves Phase 1/2 metadata and mappings and adds company-scoped
|
||||
read-only Tally master snapshots. Phase 4 adds date-scoped vouchers and their
|
||||
ledger, inventory, bill, cost-centre and bank allocations. Phase 5 adds
|
||||
reviewable Income-tax depreciation rate rules and draft calculations only.
|
||||
Existing .act files are upgraded in place and no Tally write-back occurs.
|
||||
reviewable Income-tax depreciation calculations. Phase 6 adds controlled Partner approval and audited Tally write-back for approved depreciation journals.
|
||||
Existing .act files are upgraded in place.
|
||||
"""
|
||||
|
||||
def __init__(self, storage_root: Path):
|
||||
@@ -121,6 +121,24 @@ class LocalAccountingStore:
|
||||
CREATE INDEX IF NOT EXISTS ix_{table}_master_guid ON {table}(tally_guid, master_guid);
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _ensure_phase6_columns(db: sqlite3.Connection) -> None:
|
||||
columns = {row["name"] for row in db.execute("PRAGMA table_info(it_depreciation_runs)").fetchall()}
|
||||
additions = {
|
||||
"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 ''",
|
||||
}
|
||||
for name, ddl in additions.items():
|
||||
if name not in columns:
|
||||
db.execute(f"ALTER TABLE it_depreciation_runs ADD COLUMN {name} {ddl}")
|
||||
|
||||
def initialize(self, client_id: int, client_name: str = "", tenant_id: int | None = None, created_by_user_id: int | None = None) -> Path:
|
||||
path = self.db_path(client_id)
|
||||
with self.connect(client_id) as db:
|
||||
@@ -274,8 +292,36 @@ class LocalAccountingStore:
|
||||
FOREIGN KEY(run_id) REFERENCES it_depreciation_runs(id) ON DELETE CASCADE
|
||||
);
|
||||
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_writeback_company ON tally_writeback_attempts(tally_guid, started_at_utc);
|
||||
"""
|
||||
)
|
||||
self._ensure_phase6_columns(db)
|
||||
|
||||
now = _utc_now_iso()
|
||||
meta = {
|
||||
@@ -697,9 +743,121 @@ class LocalAccountingStore:
|
||||
run=db.execute("SELECT * FROM it_depreciation_runs WHERE id=?",(int(run_id),)).fetchone()
|
||||
if not run: raise ValueError("Depreciation draft was not found.")
|
||||
lines=db.execute("SELECT * FROM it_depreciation_run_lines WHERE run_id=? ORDER BY line_no",(int(run_id),)).fetchall()
|
||||
result=dict(run); result["lines"]=[dict(x) for x in lines]; result["no_tally_writeback"]=True
|
||||
result=dict(run); result["lines"]=[dict(x) for x in lines]
|
||||
result["writeback_attempts"] = self.get_writeback_attempts(client_id, run_id) if "tally_writeback_attempts" else []
|
||||
result["no_tally_writeback"] = False
|
||||
return result
|
||||
|
||||
def approve_it_depreciation(self, client_id: int, run_id: int, approved_by_user_id: int, approval_note: str = "") -> dict:
|
||||
self.initialize(client_id)
|
||||
now = _utc_now_iso()
|
||||
with self.connect(client_id) as db:
|
||||
run = db.execute("SELECT * FROM it_depreciation_runs WHERE id=?", (int(run_id),)).fetchone()
|
||||
if not run:
|
||||
raise ValueError("Depreciation draft was not found.")
|
||||
status = str(run["status"] or "").strip().lower()
|
||||
if status == "posted":
|
||||
raise ValueError("This depreciation draft has already been posted to Tally.")
|
||||
if status not in {"draft", "approved"}:
|
||||
raise ValueError(f"Depreciation draft cannot be approved from status '{status}'.")
|
||||
if float(run["total_depreciation"] or 0) <= 0:
|
||||
raise ValueError("Depreciation amount must be greater than zero before approval.")
|
||||
if not str(run["depreciation_expense_ledger"] or "").strip() or not str(run["depreciation_reserve_ledger"] or "").strip():
|
||||
raise ValueError("Select both the depreciation expense ledger and the depreciation reserve/credit ledger before approval.")
|
||||
db.execute(
|
||||
"""UPDATE it_depreciation_runs
|
||||
SET status='approved', approved_by_user_id=?, approved_at_utc=?, approval_note=?,
|
||||
tally_posting_status=CASE WHEN tally_posting_status='failed' THEN '' ELSE tally_posting_status END,
|
||||
tally_posting_error=''
|
||||
WHERE id=?""",
|
||||
(int(approved_by_user_id), now, str(approval_note or "").strip(), int(run_id)),
|
||||
)
|
||||
return self.get_it_depreciation_run(client_id, run_id)
|
||||
|
||||
def begin_writeback_attempt(self, client_id: int, run_id: int, requested_by_user_id: int, *, voucher_date: str, reference: str, request_xml: str) -> dict:
|
||||
self.initialize(client_id)
|
||||
now = _utc_now_iso()
|
||||
with self.connect(client_id) as db:
|
||||
run = db.execute("SELECT * FROM it_depreciation_runs WHERE id=?", (int(run_id),)).fetchone()
|
||||
if not run:
|
||||
raise ValueError("Depreciation draft was not found.")
|
||||
if str(run["status"] or "").strip().lower() != "approved":
|
||||
raise ValueError("The depreciation draft must be approved before posting.")
|
||||
successful = db.execute(
|
||||
"SELECT id FROM tally_writeback_attempts WHERE depreciation_run_id=? AND status='posted' LIMIT 1",
|
||||
(int(run_id),),
|
||||
).fetchone()
|
||||
if successful:
|
||||
raise ValueError("This depreciation draft has already been posted to Tally.")
|
||||
cur = db.execute(
|
||||
"""INSERT INTO tally_writeback_attempts(
|
||||
depreciation_run_id,tally_guid,company_name,voucher_type,voucher_date,reference,
|
||||
debit_ledger,credit_ledger,amount,requested_by_user_id,started_at_utc,status,request_xml
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,'posting',?)""",
|
||||
(
|
||||
int(run_id), str(run["tally_guid"] or ""), str(run["company_name"] or ""), "Journal",
|
||||
str(voucher_date), str(reference or ""), str(run["depreciation_expense_ledger"] or ""),
|
||||
str(run["depreciation_reserve_ledger"] or ""), float(run["total_depreciation"] or 0),
|
||||
int(requested_by_user_id), now, str(request_xml or ""),
|
||||
),
|
||||
)
|
||||
attempt_id = int(cur.lastrowid)
|
||||
db.execute(
|
||||
"UPDATE it_depreciation_runs SET tally_posting_status='posting', tally_posting_error='' WHERE id=?",
|
||||
(int(run_id),),
|
||||
)
|
||||
return {"attempt_id": attempt_id, "run": self.get_it_depreciation_run(client_id, run_id)}
|
||||
|
||||
def finish_writeback_attempt(self, client_id: int, attempt_id: int, *, posted: bool, result: dict | None = None, response_xml: str = "", error_message: str = "", posted_by_user_id: int | None = None) -> dict:
|
||||
self.initialize(client_id)
|
||||
now = _utc_now_iso()
|
||||
result = result or {}
|
||||
with self.connect(client_id) as db:
|
||||
attempt = db.execute("SELECT * FROM tally_writeback_attempts WHERE id=?", (int(attempt_id),)).fetchone()
|
||||
if not attempt:
|
||||
raise ValueError("Tally write-back attempt was not found.")
|
||||
run_id = int(attempt["depreciation_run_id"])
|
||||
status = "posted" if posted else "failed"
|
||||
db.execute(
|
||||
"""UPDATE tally_writeback_attempts
|
||||
SET completed_at_utc=?, status=?, tally_created=?, tally_altered=?, tally_errors=?,
|
||||
tally_last_voucher_id=?, response_xml=?, error_message=?
|
||||
WHERE id=?""",
|
||||
(
|
||||
now, status, int(result.get("created") or 0), int(result.get("altered") or 0),
|
||||
int(result.get("errors") or 0), str(result.get("last_voucher_id") or ""),
|
||||
str(response_xml or result.get("raw_response") or ""), str(error_message or ""), int(attempt_id),
|
||||
),
|
||||
)
|
||||
if posted:
|
||||
db.execute(
|
||||
"""UPDATE it_depreciation_runs
|
||||
SET status='posted', posted_by_user_id=?, posted_at_utc=?, posted_voucher_type='Journal',
|
||||
posted_voucher_number=?, tally_posting_status='posted', tally_posting_error=''
|
||||
WHERE id=?""",
|
||||
(int(posted_by_user_id) if posted_by_user_id is not None else None, now, str(result.get("last_voucher_id") or ""), run_id),
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"""UPDATE it_depreciation_runs
|
||||
SET status='approved', tally_posting_status='failed', tally_posting_error=?
|
||||
WHERE id=?""",
|
||||
(str(error_message or "Tally rejected the journal voucher."), run_id),
|
||||
)
|
||||
return self.get_it_depreciation_run(client_id, run_id)
|
||||
|
||||
def get_writeback_attempts(self, client_id: int, run_id: int) -> list[dict]:
|
||||
self.initialize(client_id)
|
||||
with self.connect(client_id) as db:
|
||||
rows = db.execute(
|
||||
"""SELECT id,depreciation_run_id,tally_guid,company_name,voucher_type,voucher_date,reference,
|
||||
debit_ledger,credit_ledger,amount,requested_by_user_id,started_at_utc,completed_at_utc,status,
|
||||
tally_created,tally_altered,tally_errors,tally_last_voucher_id,error_message
|
||||
FROM tally_writeback_attempts WHERE depreciation_run_id=? ORDER BY id DESC""",
|
||||
(int(run_id),),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def snapshot(self, client_id: int) -> dict:
|
||||
path = self.db_path(client_id)
|
||||
if not path.is_file():
|
||||
@@ -719,7 +877,7 @@ class LocalAccountingStore:
|
||||
latest = db.execute("SELECT checked_at_utc, connected, tally_url, company_count, error_message FROM tally_connection_history ORDER BY id DESC LIMIT 1").fetchone()
|
||||
latest_sync = db.execute("SELECT id, sync_type, tally_guid, company_name, mapping_id, requested_by_user_id, status, started_at_utc, completed_at_utc, rows_processed, error_message, details_json FROM tally_sync_runs WHERE sync_type='masters' ORDER BY id DESC LIMIT 1").fetchone()
|
||||
latest_transaction_sync = db.execute("SELECT id, sync_type, tally_guid, company_name, mapping_id, requested_by_user_id, status, started_at_utc, completed_at_utc, rows_processed, error_message, details_json FROM tally_sync_runs WHERE sync_type='transactions' ORDER BY id DESC LIMIT 1").fetchone()
|
||||
latest_depreciation = db.execute("SELECT id, tally_guid, company_name, fy_start, fy_end, total_depreciation, depreciation_expense_ledger, depreciation_reserve_ledger, status, created_at_utc FROM it_depreciation_runs ORDER BY id DESC LIMIT 1").fetchone()
|
||||
latest_depreciation = db.execute("SELECT id, tally_guid, company_name, fy_start, fy_end, total_depreciation, depreciation_expense_ledger, depreciation_reserve_ledger, status, created_at_utc, approved_by_user_id, approved_at_utc, posted_by_user_id, posted_at_utc, tally_posting_status, tally_posting_error FROM it_depreciation_runs ORDER BY id DESC LIMIT 1").fetchone()
|
||||
loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)}
|
||||
mapped = []
|
||||
for row in mappings:
|
||||
|
||||
@@ -41,6 +41,10 @@ class AgentCommandProcessor:
|
||||
result = self._calculate_it_depreciation(payload)
|
||||
elif action == "accounting_get_it_depreciation_run":
|
||||
result = self._get_it_depreciation_run(payload)
|
||||
elif action == "accounting_approve_it_depreciation":
|
||||
result = self._approve_it_depreciation(payload)
|
||||
elif action == "accounting_post_it_depreciation":
|
||||
result = self._post_it_depreciation(payload)
|
||||
else:
|
||||
raise ValueError(f"Unsupported local-agent command: {action}")
|
||||
ok = True
|
||||
@@ -56,6 +60,7 @@ class AgentCommandProcessor:
|
||||
"tally_mapping_capability": True, "tally_master_sync_capability": True,
|
||||
"tally_transaction_sync_capability": True,
|
||||
"it_depreciation_capability": True,
|
||||
"tally_writeback_capability": True,
|
||||
}
|
||||
|
||||
def _status(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -207,3 +212,71 @@ class AgentCommandProcessor:
|
||||
client_id = int(payload.get("client_id")); run_id = int(payload.get("run_id"))
|
||||
return {"depreciation": self.store.get_it_depreciation_run(client_id, run_id), "agent": self._agent_info()}
|
||||
|
||||
def _approve_it_depreciation(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id"))
|
||||
run_id = int(payload.get("run_id"))
|
||||
user_id = int(payload.get("approved_by_user_id"))
|
||||
result = self.store.approve_it_depreciation(
|
||||
client_id, run_id, user_id, str(payload.get("approval_note") or "").strip()
|
||||
)
|
||||
self.logger.info("IT depreciation approved client_id=%s run_id=%s approved_by=%s", client_id, run_id, user_id)
|
||||
return {"approved": True, "depreciation": result, "agent": self._agent_info()}
|
||||
|
||||
def _post_it_depreciation(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client_id = int(payload.get("client_id"))
|
||||
run_id = int(payload.get("run_id"))
|
||||
posted_by = int(payload.get("posted_by_user_id"))
|
||||
run = self.store.get_it_depreciation_run(client_id, run_id)
|
||||
if str(run.get("status") or "").strip().lower() != "approved":
|
||||
raise ValueError("The depreciation draft must be approved before Tally write-back.")
|
||||
tally_guid = str(run.get("tally_guid") or "").strip()
|
||||
company_name = str(run.get("company_name") or "").strip()
|
||||
|
||||
tally_status = self.tally.status()
|
||||
if not tally_status.get("connected"):
|
||||
raise ValueError(str(tally_status.get("error") or "TallyPrime is not connected."))
|
||||
loaded = next(
|
||||
(row for row in (tally_status.get("companies") or [])
|
||||
if str(row.get("guid") or "").strip() == tally_guid),
|
||||
None,
|
||||
)
|
||||
if not loaded:
|
||||
raise ValueError("The mapped Tally company is not currently open. Open the mapped company in TallyPrime and retry.")
|
||||
if str(loaded.get("name") or "").strip() != company_name:
|
||||
raise ValueError("The currently open Tally company GUID matches the mapping but its company name differs. Refresh mapping/status before posting.")
|
||||
|
||||
voucher_date = str(run.get("fy_end") or "").strip()
|
||||
reference = f"ARRR-ITDEP-{run_id}"
|
||||
narration = f"Income-tax depreciation for FY {run.get('fy_start')} to {run.get('fy_end')} · ERP draft #{run_id}"
|
||||
debit = str(run.get("depreciation_expense_ledger") or "").strip()
|
||||
credit = str(run.get("depreciation_reserve_ledger") or "").strip()
|
||||
amount = float(run.get("total_depreciation") or 0)
|
||||
request_xml = self.tally.build_journal_import_xml(
|
||||
company_name, voucher_date=voucher_date, debit_ledger=debit,
|
||||
credit_ledger=credit, amount=amount, narration=narration, reference=reference,
|
||||
)
|
||||
attempt = self.store.begin_writeback_attempt(
|
||||
client_id, run_id, posted_by, voucher_date=voucher_date,
|
||||
reference=reference, request_xml=request_xml,
|
||||
)
|
||||
attempt_id = int(attempt["attempt_id"])
|
||||
try:
|
||||
result = self.tally.post_journal_voucher(
|
||||
company_name, voucher_date=voucher_date, debit_ledger=debit,
|
||||
credit_ledger=credit, amount=amount, narration=narration, reference=reference,
|
||||
)
|
||||
updated = self.store.finish_writeback_attempt(
|
||||
client_id, attempt_id, posted=True, result=result,
|
||||
response_xml=str(result.get("raw_response") or ""), posted_by_user_id=posted_by,
|
||||
)
|
||||
self.logger.warning(
|
||||
"CONTROLLED TALLY WRITEBACK posted client_id=%s company=%s run_id=%s amount=%.2f by_user=%s tally_voucher=%s",
|
||||
client_id, company_name, run_id, amount, posted_by, result.get("last_voucher_id"),
|
||||
)
|
||||
return {"posted": True, "depreciation": updated, "tally_result": {k:v for k,v in result.items() if k not in {"raw_response","request_xml"}}, "agent": self._agent_info()}
|
||||
except Exception as exc:
|
||||
self.store.finish_writeback_attempt(
|
||||
client_id, attempt_id, posted=False, result={}, error_message=str(exc), posted_by_user_id=posted_by,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@@ -99,9 +99,7 @@ class TallyCompany:
|
||||
class TallyLiveConnector:
|
||||
"""Read-only TallyPrime XML/HTTP connector used by the ERP Local Agent.
|
||||
|
||||
Phase 3 adds master discovery for the currently loaded Tally companies. Phase 4
|
||||
adds date-scoped read-only voucher/transaction export. Every export is scoped
|
||||
with SVCURRENTCOMPANY and never creates, alters or deletes Tally data.
|
||||
Phase 3 adds master discovery; Phase 4 adds transaction export. Phase 6 adds one narrowly-scoped controlled write-back operation for an approved depreciation Journal. All read operations remain read-only.
|
||||
"""
|
||||
|
||||
MASTER_SPECS = {
|
||||
@@ -286,6 +284,84 @@ class TallyLiveConnector:
|
||||
vouchers = self.export_vouchers(company_name, start, end)
|
||||
return {"date_from": start, "date_to": end, "vouchers": vouchers}
|
||||
|
||||
def build_journal_import_xml(self, company_name: str, *, voucher_date: str, debit_ledger: str, credit_ledger: str, amount: float, narration: str, reference: str = "") -> str:
|
||||
company = str(company_name or "").strip()
|
||||
debit = str(debit_ledger or "").strip()
|
||||
credit = str(credit_ledger or "").strip()
|
||||
if not company:
|
||||
raise ValueError("Tally company name is required for write-back.")
|
||||
if not debit or not credit:
|
||||
raise ValueError("Both debit and credit ledgers are required for write-back.")
|
||||
if debit.casefold() == credit.casefold():
|
||||
raise ValueError("Debit and credit ledgers cannot be the same.")
|
||||
value = round(float(amount or 0), 2)
|
||||
if value <= 0:
|
||||
raise ValueError("Journal amount must be greater than zero.")
|
||||
dt = _tally_date(voucher_date)
|
||||
ref_xml = f"<REFERENCE>{self._xml_escape(reference)}</REFERENCE>" if str(reference or "").strip() else ""
|
||||
return f"""<ENVELOPE>
|
||||
<HEADER><VERSION>1</VERSION><TALLYREQUEST>Import</TALLYREQUEST><TYPE>Data</TYPE><ID>Vouchers</ID></HEADER>
|
||||
<BODY><DESC>
|
||||
<STATICVARIABLES><SVCURRENTCOMPANY>{self._xml_escape(company)}</SVCURRENTCOMPANY></STATICVARIABLES>
|
||||
</DESC>
|
||||
<DATA><TALLYMESSAGE xmlns:UDF="TallyUDF">
|
||||
<VOUCHER VCHTYPE="Journal" ACTION="Create" OBJVIEW="Accounting Voucher View">
|
||||
<DATE>{dt}</DATE>
|
||||
<VOUCHERTYPENAME>Journal</VOUCHERTYPENAME>
|
||||
<PERSISTEDVIEW>Accounting Voucher View</PERSISTEDVIEW>
|
||||
{ref_xml}
|
||||
<NARRATION>{self._xml_escape(narration)}</NARRATION>
|
||||
<ALLLEDGERENTRIES.LIST>
|
||||
<LEDGERNAME>{self._xml_escape(debit)}</LEDGERNAME>
|
||||
<ISDEEMEDPOSITIVE>Yes</ISDEEMEDPOSITIVE>
|
||||
<AMOUNT>-{value:.2f}</AMOUNT>
|
||||
</ALLLEDGERENTRIES.LIST>
|
||||
<ALLLEDGERENTRIES.LIST>
|
||||
<LEDGERNAME>{self._xml_escape(credit)}</LEDGERNAME>
|
||||
<ISDEEMEDPOSITIVE>No</ISDEEMEDPOSITIVE>
|
||||
<AMOUNT>{value:.2f}</AMOUNT>
|
||||
</ALLLEDGERENTRIES.LIST>
|
||||
</VOUCHER>
|
||||
</TALLYMESSAGE></DATA></BODY>
|
||||
</ENVELOPE>"""
|
||||
|
||||
def post_journal_voucher(self, company_name: str, *, voucher_date: str, debit_ledger: str, credit_ledger: str, amount: float, narration: str, reference: str = "") -> dict:
|
||||
request_xml = self.build_journal_import_xml(
|
||||
company_name, voucher_date=voucher_date, debit_ledger=debit_ledger,
|
||||
credit_ledger=credit_ledger, amount=amount, narration=narration, reference=reference,
|
||||
)
|
||||
response_xml = self._post_xml(request_xml)
|
||||
cleaned = _clean_xml_response(response_xml)
|
||||
try:
|
||||
root = ET.fromstring(cleaned.encode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Tally returned invalid XML after Journal import: {exc}") from exc
|
||||
|
||||
def first_int(tag_name: str) -> int:
|
||||
text = _first_text(root, [tag_name])
|
||||
try:
|
||||
return int(float(str(text or "0")))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
created = first_int("CREATED")
|
||||
altered = first_int("ALTERED")
|
||||
errors = first_int("ERRORS")
|
||||
last_voucher_id = _first_text(root, ["LASTVCHID", "LASTMID", "LASTVOUCHERID"])
|
||||
line_error = _first_text(root, ["LINEERROR"])
|
||||
if errors > 0 or line_error:
|
||||
raise ValueError(line_error or f"Tally reported {errors} error(s) while creating the Journal voucher.")
|
||||
if created < 1 and altered < 1:
|
||||
raise ValueError("Tally did not confirm creation of the Journal voucher.")
|
||||
return {
|
||||
"created": created,
|
||||
"altered": altered,
|
||||
"errors": errors,
|
||||
"last_voucher_id": str(last_voucher_id or ""),
|
||||
"raw_response": response_xml,
|
||||
"request_xml": request_xml,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_vouchers(xml_text: str) -> list[dict]:
|
||||
cleaned = _clean_xml_response(xml_text)
|
||||
|
||||
Reference in New Issue
Block a user