From 193fd4b71ca0399c77bd0e87ccd68d9bd8f83525 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Wed, 19 Aug 2026 22:33:15 +0530 Subject: [PATCH] Add Phase 6 controlled Tally writeback --- app/core/startup.py | 2 + app/modules/accounting/act_store.py | 24 ++- .../templates/accounting/depreciation_it.html | 48 ++++- app/modules/accounting/ui.py | 78 +++++++- app/modules/core/rbac/permissions_registry.py | 2 + app/modules/documents/agent_package.py | 2 +- .../README_ERP_LOCAL_AGENT.txt | 4 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/accounting_store.py | 168 +++++++++++++++++- .../erp_local_agent/commands.py | 73 ++++++++ .../erp_local_agent/tally.py | 82 ++++++++- 11 files changed, 464 insertions(+), 21 deletions(-) diff --git a/app/core/startup.py b/app/core/startup.py index e9aa61c..200d3f4 100644 --- a/app/core/startup.py +++ b/app/core/startup.py @@ -339,6 +339,7 @@ ROLE_PERMISSION_MAP = { "accounting.tally.sync_masters", "accounting.tally.sync_transactions", "accounting.tally.depreciation_it", + "accounting.tally.writeback", "accounting.act.initialize", "clients.view.own_only", "employees.dashboard.view", @@ -975,3 +976,4 @@ def on_startup(app: FastAPI) -> None: + diff --git a/app/modules/accounting/act_store.py b/app/modules/accounting/act_store.py index 8033957..f3c6984 100644 --- a/app/modules/accounting/act_store.py +++ b/app/modules/accounting/act_store.py @@ -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), diff --git a/app/modules/accounting/templates/accounting/depreciation_it.html b/app/modules/accounting/templates/accounting/depreciation_it.html index 9d6096e..af56b2a 100644 --- a/app/modules/accounting/templates/accounting/depreciation_it.html +++ b/app/modules/accounting/templates/accounting/depreciation_it.html @@ -6,20 +6,22 @@
-

Tools · Tally · Phase 5

+

Tools · Tally · Phase 6

Depreciation (Income Tax)

-

Review synchronized Fixed Assets, assign rates ledger-wise and generate a draft depreciation calculation. No entry is posted to Tally.

+

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.

Back to Tally
- {% if calculated %}
Income-tax depreciation draft calculated and saved in the client accounting database. Nothing was posted to Tally.
{% endif %} + {% if calculated %}
Income-tax depreciation draft calculated and saved. It still requires explicit Partner approval before Tally write-back.
{% endif %} + {% if approved %}
Depreciation draft approved. No Tally voucher has been created yet.
{% endif %} + {% if posted %}
Approved depreciation Journal posted to TallyPrime successfully.
{% endif %} {% if command_error %}
{{ command_error }}
{% endif %}
ERP Local Agent
{{ 'Connected' if agent_online else 'Offline' }}
Agent {{ agent.version if agent and agent.version else '-' }}
TallyPrime
{{ 'Connected' if tally and tally.connected else 'Not connected' }}
{{ tally.company_count if tally else 0 }} currently loaded
-
Phase 5
{{ 'Available' if agent and agent.it_depreciation_capability else 'Update Agent' }}
Draft calculation only
+
Phase 6
{{ 'Available' if agent and agent.tally_writeback_capability else 'Update Agent' }}
Controlled approved write-back
@@ -44,7 +46,7 @@
-

These ledger selections are stored with the draft for the proposed journal. Phase 5 does not create or post any Tally voucher.

+

These ledger selections are stored with the draft. Phase 6 can post the Journal only after the saved draft is explicitly approved.

@@ -59,11 +61,43 @@ {% if depreciation_run %}

Draft Calculation #{{ depreciation_run.id }}

{{ depreciation_run.company_name }} · {{ depreciation_run.fy_start }} to {{ depreciation_run.fy_end }}

Total Depreciation
₹ {{ '%.2f'|format(depreciation_run.total_depreciation) }}
-
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.
+
Journal: 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) }}. Voucher date will be {{ depreciation_run.fy_end }} and reference ARRR-ITDEP-{{ depreciation_run.id }}.
+
+
Draft Status
{{ depreciation_run.status|upper }}
+
Approved
{{ depreciation_run.approved_at_utc or 'Not yet approved' }}
+
Tally Posting
{{ depreciation_run.tally_posting_status|upper if depreciation_run.tally_posting_status else 'NOT POSTED' }}
+
+ {% if depreciation_run.tally_posting_error %}
{{ depreciation_run.tally_posting_error }}
{% endif %} +
{% for row in depreciation_run.lines %}{% endfor %}
LedgerRateDepreciationClosing WDV
{{ row.ledger_name }}{{ '%.2f'|format(row.rate_percent) }}%{{ '%.2f'|format(row.depreciation_amount) }}{{ '%.2f'|format(row.closing_wdv) }}
+ {% if depreciation_run.status == 'draft' %} +
+ +
Partner Approval Required
+

Review the company, financial year, ledgers, rates and total before approving. Approval does not post anything to Tally.

+ + + +
+ {% elif depreciation_run.status == 'approved' %} +
+ +
Controlled Tally Write-back
+

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.

+ +
+ +
+ {% elif depreciation_run.status == 'posted' %} +
Posted successfully. 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.
+ {% endif %} + + {% if depreciation_run.writeback_attempts %} +

Write-back Audit Trail

{% for row in depreciation_run.writeback_attempts %}{% endfor %}
AttemptStartedStatusTally resultError
#{{ row.id }}{{ row.started_at_utc }}{{ row.status|upper }}Created {{ row.tally_created }} · Errors {{ row.tally_errors }}{% if row.tally_last_voucher_id %} · ID {{ row.tally_last_voucher_id }}{% endif %}{{ row.error_message or '' }}
+ {% endif %}
{% endif %} -
Phase 5 is review/calculation only. Phase 6 will add controlled, approved Tally write-back.
+
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.
{% endblock %} diff --git a/app/modules/accounting/ui.py b/app/modules/accounting/ui.py index 1eedf57..b41bb7b 100644 --- a/app/modules/accounting/ui.py +++ b/app/modules/accounting/ui.py @@ -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() diff --git a/app/modules/core/rbac/permissions_registry.py b/app/modules/core/rbac/permissions_registry.py index eba9f00..58136f5 100644 --- a/app/modules/core/rbac/permissions_registry.py +++ b/app/modules/core/rbac/permissions_registry.py @@ -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]: + diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 69fce46..f0ddb37 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -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) diff --git a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt index ccfd193..6dae74c 100644 --- a/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt +++ b/app/modules/documents/local_agent_runtime/README_ERP_LOCAL_AGENT.txt @@ -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. diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 7935cfd..632e9d7 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.6.0" +__version__ = "1.7.0" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py index 5b0e787..f55d8fd 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/accounting_store.py @@ -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: diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index f7be368..e1f0781 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -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 + diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py index dfd8846..1e4df9d 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py @@ -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"{self._xml_escape(reference)}" if str(reference or "").strip() else "" + return f""" +
1ImportDataVouchers
+ + {self._xml_escape(company)} + + + + {dt} + Journal + Accounting Voucher View + {ref_xml} + {self._xml_escape(narration)} + + {self._xml_escape(debit)} + Yes + -{value:.2f} + + + {self._xml_escape(credit)} + No + {value:.2f} + + + +
""" + + 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)