diff --git a/app/core/startup.py b/app/core/startup.py
index b59aa73..e9aa61c 100644
--- a/app/core/startup.py
+++ b/app/core/startup.py
@@ -338,6 +338,7 @@ ROLE_PERMISSION_MAP = {
"accounting.tally.map_company",
"accounting.tally.sync_masters",
"accounting.tally.sync_transactions",
+ "accounting.tally.depreciation_it",
"accounting.act.initialize",
"clients.view.own_only",
"employees.dashboard.view",
@@ -973,3 +974,4 @@ def on_startup(app: FastAPI) -> None:
+
diff --git a/app/modules/accounting/act_store.py b/app/modules/accounting/act_store.py
index fb7535a..8033957 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 = 4
+ACT_SCHEMA_VERSION = 5
class AccountingActStoreError(RuntimeError):
@@ -32,7 +32,8 @@ class AccountingActStore:
Phase 1 provides accounting storage and read-only Tally discovery.
Phase 2 adds durable client/registration -> Tally company mapping keyed by
Tally GUID. Phase 3 adds read-only Tally accounting master snapshot tables.
- Phase 4 adds read-only voucher/transaction tables. Tally write-back remains
+ Phase 4 adds read-only voucher/transaction tables. Phase 5 adds draft-only
+ Income-tax depreciation rules and calculation tables. Tally write-back remains
out of scope.
"""
@@ -199,7 +200,27 @@ class AccountingActStore:
);
CREATE INDEX IF NOT EXISTS ix_tally_vouchers_company_date ON tally_vouchers(tally_guid, voucher_date);
CREATE INDEX IF NOT EXISTS ix_tally_voucher_ledger_entries_voucher ON tally_voucher_ledger_entries(voucher_id, line_no);
- CREATE INDEX IF NOT EXISTS ix_tally_voucher_inventory_entries_voucher ON tally_voucher_inventory_entries(voucher_id, line_no);
+ CREATE INDEX IF NOT EXISTS ix_tally_bank_allocations_ledger_entry ON tally_bank_allocations(ledger_entry_id, line_no);
+
+ CREATE TABLE IF NOT EXISTS it_depreciation_rate_rules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, tally_guid TEXT NOT NULL, ledger_key TEXT NOT NULL, ledger_guid TEXT NOT NULL DEFAULT '',
+ ledger_name TEXT NOT NULL, rate_percent REAL NOT NULL DEFAULT 0, updated_by_user_id INTEGER, updated_at_utc TEXT NOT NULL,
+ UNIQUE(tally_guid, ledger_key)
+ );
+ CREATE TABLE IF NOT EXISTS it_depreciation_runs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, tally_guid TEXT NOT NULL, company_name TEXT NOT NULL, mapping_id INTEGER,
+ fy_start TEXT NOT NULL, fy_end TEXT NOT NULL, depreciation_expense_ledger TEXT NOT NULL DEFAULT '', depreciation_reserve_ledger TEXT NOT NULL DEFAULT '',
+ requested_by_user_id INTEGER, status TEXT NOT NULL DEFAULT 'draft', created_at_utc TEXT NOT NULL, total_depreciation REAL NOT NULL DEFAULT 0, details_json TEXT NOT NULL DEFAULT '{}'
+ );
+ CREATE TABLE IF NOT EXISTS it_depreciation_run_lines (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, run_id INTEGER NOT NULL, line_no INTEGER NOT NULL, ledger_key TEXT NOT NULL, ledger_guid TEXT NOT NULL DEFAULT '',
+ ledger_name TEXT NOT NULL, parent_group TEXT NOT NULL DEFAULT '', opening_wdv REAL NOT NULL DEFAULT 0, additions_full_rate REAL NOT NULL DEFAULT 0,
+ additions_half_rate REAL NOT NULL DEFAULT 0, reductions REAL NOT NULL DEFAULT 0, rate_percent REAL NOT NULL DEFAULT 0, depreciation_amount REAL NOT NULL DEFAULT 0,
+ closing_wdv REAL NOT NULL DEFAULT 0, payload_json TEXT NOT NULL DEFAULT '{}', FOREIGN KEY(run_id) REFERENCES it_depreciation_runs(id) ON DELETE CASCADE
+ );
+ CREATE INDEX IF NOT EXISTS ix_it_dep_rate_rules_company ON it_depreciation_rate_rules(tally_guid, ledger_name);
+ 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 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);
diff --git a/app/modules/accounting/templates/accounting/depreciation_it.html b/app/modules/accounting/templates/accounting/depreciation_it.html
new file mode 100644
index 0000000..9d6096e
--- /dev/null
+++ b/app/modules/accounting/templates/accounting/depreciation_it.html
@@ -0,0 +1,69 @@
+{% extends "ui/templates/base/layout.html" %}
+{% block content %}
+{% set agent = live_result.agent if live_result else None %}
+{% set tally = live_result.tally if live_result else None %}
+{% set accounting = live_result.accounting if live_result else None %}
+
+
+
+
Tools · Tally · Phase 5
+
Depreciation (Income Tax)
+
Review synchronized Fixed Assets, assign rates ledger-wise and generate a draft depreciation calculation. No entry is posted to Tally.
+
+
Back to Tally
+
+
+ {% if calculated %}
Income-tax depreciation draft calculated and saved in the client accounting database. Nothing was posted to Tally.
{% 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
+
+
+
+
+ {% if preview %}
+ {% set coverage = preview.transaction_coverage %}
+
+ {% if coverage.complete %}Phase 4 transaction data completely covers {{ preview.fy_start }} to {{ preview.fy_end }}.{% else %}Phase 4 transaction data does not fully cover this financial year. Synchronize the missing period before calculation.{% endif %}
+
+
+
+ {% endif %}
+
+ {% 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.
+ | Ledger | Rate | Depreciation | Closing WDV |
{% for row in depreciation_run.lines %}| {{ row.ledger_name }} | {{ '%.2f'|format(row.rate_percent) }}% | {{ '%.2f'|format(row.depreciation_amount) }} | {{ '%.2f'|format(row.closing_wdv) }} |
{% endfor %}
+
+ {% endif %}
+
+
Phase 5 is review/calculation only. Phase 6 will add controlled, approved Tally write-back.
+
+{% endblock %}
diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html
index b5ed518..f3029c8 100644
--- a/app/modules/accounting/templates/accounting/tally.html
+++ b/app/modules/accounting/templates/accounting/tally.html
@@ -9,10 +9,13 @@
Tools · Accounting
-
Tally Accounting Masters
-
Phase 3 permanently maps an ERP client or registration to a loaded TallyPrime company using the Tally GUID.
+
Tally Accounting
+
Company mapping, master sync and transaction sync remain available. Phase 5 adds review-only accounting tools without Tally write-back.
+
+
-
Refresh Tally Companies
{% if initialized %}
diff --git a/app/modules/accounting/ui.py b/app/modules/accounting/ui.py
index 3d1c532..1eedf57 100644
--- a/app/modules/accounting/ui.py
+++ b/app/modules/accounting/ui.py
@@ -469,3 +469,77 @@ def sync_tally_transactions(
return RedirectResponse(url=f"/tools/tally?client_id={client.id}&refresh=1&transactions_synced=1", status_code=303)
finally:
db.close()
+
+@router.get("/depreciation")
+def depreciation_it_tool(
+ request: Request,
+ client_id: int | None = None,
+ tally_guid: str = "",
+ fy_start: str = "",
+ fy_end: str = "",
+ calculated: int = 0,
+ run_id: int | None = None,
+ error: str = "",
+):
+ db = CommonSessionLocal()
+ try:
+ user, response = _require_partner(request, db, "accounting.tally.depreciation_it")
+ if response: return response
+ clients, scope = _visible_clients(db, request, user)
+ selected_client = next((row for row in clients if client_id and int(row.id) == int(client_id)), None)
+ node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id)
+ online = _node_online(node)
+ today = date.today(); start_year = today.year if today.month >= 4 else today.year - 1
+ start_text = fy_start or date(start_year,4,1).isoformat(); end_text = fy_end or date(start_year+1,3,31).isoformat()
+ 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)
+ 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()
+ if chosen_guid:
+ preview_response=request_agent_command(node.node_code,"accounting_depreciation_preview",{"client_id":int(selected_client.id),"tally_guid":chosen_guid,"fy_start":start_text,"fy_end":end_text},timeout_seconds=60)
+ if preview_response.get("ok"): preview=(preview_response.get("result") or {}).get("preview")
+ else: command_error=str(preview_response.get("error") or "Depreciation preview failed.")
+ if run_id:
+ run_response=request_agent_command(node.node_code,"accounting_get_it_depreciation_run",{"client_id":int(selected_client.id),"run_id":int(run_id)},timeout_seconds=30)
+ 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})
+ return templates.TemplateResponse("modules/accounting/templates/accounting/depreciation_it.html",base)
+ finally: db.close()
+
+
+@router.post("/depreciation/calculate")
+async def calculate_it_depreciation(request: Request):
+ form = await request.form()
+ csrf_token = str(form.get("csrf_token") or ""); validate_csrf(request, csrf_token)
+ db = CommonSessionLocal()
+ try:
+ user, response = _require_partner(request, db, "accounting.tally.depreciation_it")
+ if response: return response
+ try: client_id=int(form.get("client_id")); start=date.fromisoformat(str(form.get("fy_start") or "")); end=date.fromisoformat(str(form.get("fy_end") or ""))
+ except Exception as exc: return RedirectResponse(url=f"/tools/tally/depreciation?error={quote('Invalid client or financial-year dates.')}",status_code=303)
+ client, _clients, scope = _find_visible_client(db, request, user, client_id)
+ if not client: return _denied()
+ tally_guid=str(form.get("tally_guid") or "").strip()
+ assignments=[]
+ for key,value in form.multi_items():
+ if not str(key).startswith("rate__"): continue
+ ledger_key=str(key)[6:]
+ try: rate=float(str(value or "0").strip() or 0)
+ except Exception: rate=-1
+ assignments.append({"ledger_key":ledger_key,"rate_percent":rate})
+ 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}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote('ERP Local Agent is offline for the active branch.')}",status_code=303)
+ try:
+ result=request_agent_command(node.node_code,"accounting_calculate_it_depreciation",{"client_id":int(client.id),"tally_guid":tally_guid,"fy_start":start.isoformat(),"fy_end":end.isoformat(),"assignments":assignments,"depreciation_expense_ledger":str(form.get("depreciation_expense_ledger") or ""),"depreciation_reserve_ledger":str(form.get("depreciation_reserve_ledger") or ""),"requested_by_user_id":int(user.id)},timeout_seconds=120)
+ if not result.get("ok"): raise RuntimeError(str(result.get("error") or "Income-tax depreciation calculation failed."))
+ dep=(result.get("result") or {}).get("depreciation") or {}; rid=int(dep.get("run_id"))
+ except Exception as exc:
+ return RedirectResponse(url=f"/tools/tally/depreciation?client_id={client.id}&tally_guid={quote(tally_guid)}&fy_start={start.isoformat()}&fy_end={end.isoformat()}&error={quote(str(exc))}",status_code=303)
+ 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()
+
diff --git a/app/modules/core/rbac/permissions_registry.py b/app/modules/core/rbac/permissions_registry.py
index de9d9f6..eba9f00 100644
--- a/app/modules/core/rbac/permissions_registry.py
+++ b/app/modules/core/rbac/permissions_registry.py
@@ -152,6 +152,7 @@ PERMISSIONS = {
"accounting.tally.map_company": "Map ERP Client or Registration to Tally Company",
"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.act.initialize": "Initialize Client Accounting ACT Storage",
"notice_cases.view": "View Notice and Case Management",
@@ -183,3 +184,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 92770fb..69fce46 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.5.0"
+ERP_LOCAL_AGENT_VERSION = "1.6.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 ce43e63..ccfd193 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
@@ -21,4 +21,6 @@ Phase 2: client/registration to Tally company mapping is supported using Tally G
Phase 3: read-only Tally accounting master sync (Groups, Ledgers, Voucher Types, Stock Groups/Categories/Items, Units and Cost Centres/Categories) into client .act storage.
-Version 1.5.0: Phase 4 read-only Tally transaction synchronization (vouchers, ledger/inventory entries, bill/cost-centre/bank allocations) with raw voucher XML preservation.
+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.
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 7cda226..7935cfd 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.5.0"
+__version__ = "1.6.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 198a9fd..5b0e787 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
@@ -1,13 +1,13 @@
from __future__ import annotations
-from datetime import datetime, timezone
+from datetime import date, datetime, timedelta, timezone
import json
from pathlib import Path
import sqlite3
from typing import Sequence
-SCHEMA_VERSION = "4"
+SCHEMA_VERSION = "5"
MASTER_TABLES = {
"groups": "tally_groups",
@@ -31,8 +31,9 @@ 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. Existing .act files
- are upgraded in place.
+ 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.
"""
def __init__(self, storage_root: Path):
@@ -244,6 +245,35 @@ class LocalAccountingStore:
FOREIGN KEY(ledger_entry_id) REFERENCES tally_voucher_ledger_entries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS ix_tally_bank_allocations_ledger_entry ON tally_bank_allocations(ledger_entry_id, line_no);
+
+ CREATE TABLE IF NOT EXISTS it_depreciation_rate_rules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ tally_guid TEXT NOT NULL, ledger_key TEXT NOT NULL, ledger_guid TEXT NOT NULL DEFAULT '',
+ ledger_name TEXT NOT NULL, rate_percent REAL NOT NULL DEFAULT 0,
+ updated_by_user_id INTEGER, updated_at_utc TEXT NOT NULL,
+ UNIQUE(tally_guid, ledger_key)
+ );
+ CREATE INDEX IF NOT EXISTS ix_it_dep_rate_rules_company ON it_depreciation_rate_rules(tally_guid, ledger_name);
+
+ CREATE TABLE IF NOT EXISTS it_depreciation_runs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, tally_guid TEXT NOT NULL, company_name TEXT NOT NULL,
+ mapping_id INTEGER, fy_start TEXT NOT NULL, fy_end TEXT NOT NULL,
+ depreciation_expense_ledger TEXT NOT NULL DEFAULT '', depreciation_reserve_ledger TEXT NOT NULL DEFAULT '',
+ requested_by_user_id INTEGER, status TEXT NOT NULL DEFAULT 'draft', created_at_utc TEXT NOT NULL,
+ total_depreciation REAL NOT NULL DEFAULT 0, details_json TEXT NOT NULL DEFAULT '{}'
+ );
+ CREATE INDEX IF NOT EXISTS ix_it_dep_runs_company_fy ON it_depreciation_runs(tally_guid, fy_start, fy_end, id);
+
+ CREATE TABLE IF NOT EXISTS it_depreciation_run_lines (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, run_id INTEGER NOT NULL, line_no INTEGER NOT NULL,
+ ledger_key TEXT NOT NULL, ledger_guid TEXT NOT NULL DEFAULT '', ledger_name TEXT NOT NULL, parent_group TEXT NOT NULL DEFAULT '',
+ opening_wdv REAL NOT NULL DEFAULT 0, additions_full_rate REAL NOT NULL DEFAULT 0,
+ additions_half_rate REAL NOT NULL DEFAULT 0, reductions REAL NOT NULL DEFAULT 0,
+ rate_percent REAL NOT NULL DEFAULT 0, depreciation_amount REAL NOT NULL DEFAULT 0,
+ closing_wdv REAL NOT NULL DEFAULT 0, payload_json TEXT NOT NULL DEFAULT '{}',
+ 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);
"""
)
@@ -517,6 +547,159 @@ class LocalAccountingStore:
result[key] = int(db.execute(f"SELECT COUNT(*) FROM {table} WHERE tally_guid=?", (tally_guid,)).fetchone()[0])
return result
+ @staticmethod
+ def _ledger_key(row: dict) -> str:
+ guid = str(row.get("master_guid") or row.get("guid") or "").strip()
+ name = str(row.get("name") or row.get("ledger_name") or "").strip()
+ return ("G:" + guid) if guid else ("N:" + name.casefold())
+
+ @staticmethod
+ def _is_debit_entry(row: sqlite3.Row | dict) -> bool:
+ flag = str(row["is_deemed_positive"] if isinstance(row, sqlite3.Row) else row.get("is_deemed_positive") or "").strip().lower()
+ if flag in {"yes", "true", "1"}:
+ return True
+ if flag in {"no", "false", "0"}:
+ return False
+ amount = float(row["amount"] if isinstance(row, sqlite3.Row) else row.get("amount") or 0)
+ return amount < 0
+
+ @staticmethod
+ def _half_rate_addition(voucher_date: str, fy_end: date) -> bool:
+ try:
+ acquired = date.fromisoformat(str(voucher_date or ""))
+ except Exception:
+ return False
+ # Transaction date is a review proxy for put-to-use date. Half-rate is
+ # flagged only when fewer than 180 calendar days remain including that date.
+ return ((fy_end - acquired).days + 1) < 180
+
+ def _fixed_asset_group_names(self, db: sqlite3.Connection, tally_guid: str) -> set[str]:
+ rows = db.execute("SELECT name, parent FROM tally_groups WHERE tally_guid=?", (tally_guid,)).fetchall()
+ all_groups = {str(r["name"] or "").strip(): str(r["parent"] or "").strip() for r in rows}
+ fixed = {name for name in all_groups if name.casefold() == "fixed assets"}
+ changed = True
+ while changed:
+ changed = False
+ fixed_fold = {x.casefold() for x in fixed}
+ for name, parent in all_groups.items():
+ if name not in fixed and parent.casefold() in fixed_fold:
+ fixed.add(name); changed = True
+ return fixed
+
+ def _transaction_coverage(self, db: sqlite3.Connection, tally_guid: str, fy_start: date, fy_end: date) -> dict:
+ rows = db.execute(
+ "SELECT details_json FROM tally_sync_runs WHERE sync_type='transactions' AND tally_guid=? AND status='completed' ORDER BY id",
+ (tally_guid,),
+ ).fetchall()
+ ranges = []
+ for row in rows:
+ try:
+ d = json.loads(row["details_json"] or "{}")
+ a = date.fromisoformat(str(d.get("date_from") or "")); b = date.fromisoformat(str(d.get("date_to") or ""))
+ if b >= fy_start and a <= fy_end:
+ ranges.append((max(a, fy_start), min(b, fy_end)))
+ except Exception:
+ continue
+ ranges.sort()
+ merged = []
+ for a,b in ranges:
+ if not merged or a > merged[-1][1] + timedelta(days=1): merged.append([a,b])
+ elif b > merged[-1][1]: merged[-1][1] = b
+ complete = bool(merged and merged[0][0] <= fy_start and merged[-1][1] >= fy_end and all(merged[i][1] + timedelta(days=1) >= merged[i+1][0] for i in range(len(merged)-1)))
+ return {"complete": complete, "ranges": [{"from":a.isoformat(),"to":b.isoformat()} for a,b in merged]}
+
+ def depreciation_preview(self, client_id: int, *, tally_guid: str, fy_start: str, fy_end: str) -> dict:
+ if not self.exists(client_id):
+ raise ValueError("Accounting storage is not initialized for this client.")
+ mapping = self.get_active_mapping_by_guid(client_id, tally_guid)
+ start = date.fromisoformat(str(fy_start)); end = date.fromisoformat(str(fy_end))
+ if start > end: raise ValueError("Financial-year start cannot be after end date.")
+ with self.connect(client_id) as db:
+ fixed_groups = self._fixed_asset_group_names(db, tally_guid)
+ if not fixed_groups:
+ raise ValueError("Fixed Assets group was not found in synchronized Tally masters. Synchronize Phase 3 masters first.")
+ ledgers = db.execute(
+ "SELECT master_guid, name, parent, opening_balance, closing_balance, payload_json FROM tally_ledgers WHERE tally_guid=? ORDER BY name COLLATE NOCASE",
+ (tally_guid,),
+ ).fetchall()
+ rule_rows = db.execute("SELECT ledger_key, rate_percent FROM it_depreciation_rate_rules WHERE tally_guid=?", (tally_guid,)).fetchall()
+ rules = {str(r["ledger_key"]): float(r["rate_percent"] or 0) for r in rule_rows}
+ coverage = self._transaction_coverage(db, tally_guid, start, end)
+ fixed_fold = {x.casefold() for x in fixed_groups}
+ result=[]
+ for row in ledgers:
+ if str(row["parent"] or "").strip().casefold() not in fixed_fold: continue
+ item=dict(row); key=self._ledger_key(item); name=str(row["name"] or "").strip()
+ entries=db.execute(
+ """SELECT v.voucher_date, e.amount, e.is_deemed_positive
+ FROM tally_voucher_ledger_entries e JOIN tally_vouchers v ON v.id=e.voucher_id
+ WHERE v.tally_guid=? AND e.ledger_name=? AND v.voucher_date>=? AND v.voucher_date<=?
+ AND lower(COALESCE(v.is_cancelled,'')) NOT IN ('yes','true','1')
+ AND lower(COALESCE(v.is_optional,'')) NOT IN ('yes','true','1')""",
+ (tally_guid,name,start.isoformat(),end.isoformat()),
+ ).fetchall()
+ full=half=reductions=0.0
+ for e in entries:
+ amt=abs(float(e["amount"] or 0))
+ if self._is_debit_entry(e):
+ if self._half_rate_addition(str(e["voucher_date"] or ""), end): half += amt
+ else: full += amt
+ else: reductions += amt
+ result.append({
+ "ledger_key":key,"ledger_guid":str(row["master_guid"] or ""),"ledger_name":name,"parent_group":str(row["parent"] or ""),
+ "opening_wdv":abs(float(row["opening_balance"] or 0)),"tally_closing_balance":abs(float(row["closing_balance"] or 0)),
+ "additions_full_rate":round(full,2),"additions_half_rate":round(half,2),"reductions":round(reductions,2),
+ "rate_percent":rules.get(key,0.0),
+ })
+ all_ledgers=[str(r["name"] or "").strip() for r in ledgers if str(r["name"] or "").strip()]
+ latest=db.execute("SELECT id, fy_start, fy_end, total_depreciation, depreciation_expense_ledger, depreciation_reserve_ledger, created_at_utc FROM it_depreciation_runs WHERE tally_guid=? ORDER BY id DESC LIMIT 1",(tally_guid,)).fetchone()
+ return {"mapping":mapping,"fy_start":start.isoformat(),"fy_end":end.isoformat(),"transaction_coverage":coverage,"fixed_asset_groups":sorted(fixed_groups,key=str.casefold),"fixed_asset_ledgers":result,"all_ledgers":all_ledgers,"latest_run":dict(latest) if latest else None}
+
+ def calculate_it_depreciation(self, client_id: int, *, tally_guid: str, fy_start: str, fy_end: str, assignments: list[dict], depreciation_expense_ledger: str = "", depreciation_reserve_ledger: str = "", requested_by_user_id: int | None = None) -> dict:
+ preview=self.depreciation_preview(client_id,tally_guid=tally_guid,fy_start=fy_start,fy_end=fy_end)
+ if not preview["transaction_coverage"].get("complete"):
+ raise ValueError("Phase 4 transaction data does not completely cover the selected financial year. Synchronize the full financial year before calculating depreciation.")
+ by_key={str(x.get("ledger_key")):x for x in preview["fixed_asset_ledgers"]}
+ assignment_map={str(x.get("ledger_key") or ""): float(x.get("rate_percent") or 0) for x in assignments}
+ unknown=[k for k in assignment_map if k not in by_key]
+ if unknown: raise ValueError("One or more submitted fixed-asset ledgers are no longer available. Refresh the depreciation page.")
+ for rate in assignment_map.values():
+ if rate < 0 or rate > 100: raise ValueError("Depreciation rate must be between 0% and 100%.")
+ now=_utc_now_iso(); lines=[]; total=0.0
+ for index,row in enumerate(preview["fixed_asset_ledgers"],1):
+ rate=float(assignment_map.get(row["ledger_key"], row.get("rate_percent") or 0))
+ opening=float(row["opening_wdv"] or 0); full=float(row["additions_full_rate"] or 0); half=float(row["additions_half_rate"] or 0); reductions=float(row["reductions"] or 0)
+ closing_before=max(0.0, opening+full+half-reductions)
+ full_base=max(0.0, opening+full-reductions)
+ dep=min(closing_before, (full_base*rate/100.0)+(half*rate/200.0))
+ dep=round(dep,2); closing=round(max(0.0,closing_before-dep),2); total+=dep
+ line={**row,"rate_percent":rate,"depreciation_amount":dep,"closing_wdv":closing,"closing_before_depreciation":round(closing_before,2)}
+ lines.append(line)
+ mapping=preview["mapping"]
+ with self.connect(client_id) as db:
+ for row in lines:
+ db.execute("""INSERT INTO it_depreciation_rate_rules(tally_guid,ledger_key,ledger_guid,ledger_name,rate_percent,updated_by_user_id,updated_at_utc)
+ VALUES(?,?,?,?,?,?,?) ON CONFLICT(tally_guid,ledger_key) DO UPDATE SET ledger_guid=excluded.ledger_guid,ledger_name=excluded.ledger_name,rate_percent=excluded.rate_percent,updated_by_user_id=excluded.updated_by_user_id,updated_at_utc=excluded.updated_at_utc""",
+ (tally_guid,row["ledger_key"],row["ledger_guid"],row["ledger_name"],row["rate_percent"],requested_by_user_id,now))
+ details={"calculation_basis":"Ledger-level draft using synchronized opening balance and voucher movements; transaction date is used as the review proxy for put-to-use date for the 180-day test.","transaction_coverage":preview["transaction_coverage"],"no_tally_writeback":True}
+ cur=db.execute("""INSERT INTO it_depreciation_runs(tally_guid,company_name,mapping_id,fy_start,fy_end,depreciation_expense_ledger,depreciation_reserve_ledger,requested_by_user_id,status,created_at_utc,total_depreciation,details_json)
+ VALUES(?,?,?,?,?,?,?,?, 'draft',?,?,?)""",
+ (tally_guid,str(mapping.get("company_name") or ""),mapping.get("id"),str(fy_start),str(fy_end),str(depreciation_expense_ledger or ""),str(depreciation_reserve_ledger or ""),requested_by_user_id,now,round(total,2),json.dumps(details,ensure_ascii=False,separators=(",",":"))))
+ run_id=int(cur.lastrowid)
+ for i,row in enumerate(lines,1):
+ db.execute("""INSERT INTO it_depreciation_run_lines(run_id,line_no,ledger_key,ledger_guid,ledger_name,parent_group,opening_wdv,additions_full_rate,additions_half_rate,reductions,rate_percent,depreciation_amount,closing_wdv,payload_json)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
+ (run_id,i,row["ledger_key"],row["ledger_guid"],row["ledger_name"],row["parent_group"],row["opening_wdv"],row["additions_full_rate"],row["additions_half_rate"],row["reductions"],row["rate_percent"],row["depreciation_amount"],row["closing_wdv"],json.dumps(row,ensure_ascii=False,separators=(",",":"))))
+ return {"run_id":run_id,"status":"draft","company_name":str(mapping.get("company_name") or ""),"fy_start":str(fy_start),"fy_end":str(fy_end),"total_depreciation":round(total,2),"depreciation_expense_ledger":str(depreciation_expense_ledger or ""),"depreciation_reserve_ledger":str(depreciation_reserve_ledger or ""),"lines":lines,"proposed_entry":{"debit_ledger":str(depreciation_expense_ledger or "Depreciation"),"credit_ledger":str(depreciation_reserve_ledger or "Depreciation Reserve"),"amount":round(total,2)},"no_tally_writeback":True}
+
+ def get_it_depreciation_run(self, client_id: int, run_id: int) -> dict:
+ 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.")
+ 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
+ return result
+
def snapshot(self, client_id: int) -> dict:
path = self.db_path(client_id)
if not path.is_file():
@@ -536,6 +719,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()
loaded_guids = {str(row["guid"] or "") for row in companies if int(row["is_currently_loaded"] or 0)}
mapped = []
for row in mappings:
@@ -565,4 +749,5 @@ class LocalAccountingStore:
"mappings": mapped,
"latest_master_sync": latest_sync_dict,
"latest_transaction_sync": latest_transaction_sync_dict,
+ "latest_it_depreciation": dict(latest_depreciation) if latest_depreciation else None,
}
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 aed86ca..f7be368 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
@@ -23,7 +23,7 @@ class AgentCommandProcessor:
error: str | None = None
ok = False
try:
- if action in {"tally_status", "phase1_status", "phase2_status", "phase3_status", "phase4_status"}:
+ if action in {"tally_status", "phase1_status", "phase2_status", "phase3_status", "phase4_status", "phase5_status"}:
result = self._status(payload)
elif action == "accounting_initialize":
result = self._initialize(payload)
@@ -35,6 +35,12 @@ class AgentCommandProcessor:
result = self._sync_masters(payload)
elif action == "accounting_sync_transactions":
result = self._sync_transactions(payload)
+ elif action == "accounting_depreciation_preview":
+ result = self._depreciation_preview(payload)
+ elif action == "accounting_calculate_it_depreciation":
+ result = self._calculate_it_depreciation(payload)
+ elif action == "accounting_get_it_depreciation_run":
+ result = self._get_it_depreciation_run(payload)
else:
raise ValueError(f"Unsupported local-agent command: {action}")
ok = True
@@ -49,6 +55,7 @@ class AgentCommandProcessor:
"tally_capability": True, "accounting_act_capability": True,
"tally_mapping_capability": True, "tally_master_sync_capability": True,
"tally_transaction_sync_capability": True,
+ "it_depreciation_capability": True,
}
def _status(self, payload: dict[str, Any]) -> dict[str, Any]:
@@ -172,3 +179,31 @@ class AgentCommandProcessor:
)
self.logger.info("Tally transaction sync completed client_id=%s company=%s period=%s..%s rows=%s", client_id, company_name, date_from, date_to, sync.get("rows_processed"))
return {"synced": True, "sync": sync, "accounting": self.store.snapshot(client_id), "tally": tally_status, "agent": self._agent_info()}
+
+ def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]:
+ client_id = int(payload.get("client_id"))
+ tally_guid = str(payload.get("tally_guid") or "").strip()
+ fy_start = str(payload.get("fy_start") or "").strip()
+ fy_end = str(payload.get("fy_end") or "").strip()
+ if not tally_guid: raise ValueError("Select a mapped Tally company for depreciation.")
+ return {"preview": self.store.depreciation_preview(client_id, tally_guid=tally_guid, fy_start=fy_start, fy_end=fy_end), "agent": self._agent_info()}
+
+ def _calculate_it_depreciation(self, payload: dict[str, Any]) -> dict[str, Any]:
+ client_id = int(payload.get("client_id"))
+ result = self.store.calculate_it_depreciation(
+ client_id,
+ tally_guid=str(payload.get("tally_guid") or "").strip(),
+ fy_start=str(payload.get("fy_start") or "").strip(),
+ fy_end=str(payload.get("fy_end") or "").strip(),
+ assignments=list(payload.get("assignments") or []),
+ depreciation_expense_ledger=str(payload.get("depreciation_expense_ledger") or "").strip(),
+ depreciation_reserve_ledger=str(payload.get("depreciation_reserve_ledger") or "").strip(),
+ requested_by_user_id=int(payload.get("requested_by_user_id")) if payload.get("requested_by_user_id") not in (None, "") else None,
+ )
+ self.logger.info("IT depreciation draft calculated client_id=%s company=%s run_id=%s total=%s", client_id, result.get("company_name"), result.get("run_id"), result.get("total_depreciation"))
+ return {"calculated": True, "depreciation": result, "accounting": self.store.snapshot(client_id), "agent": self._agent_info()}
+
+ def _get_it_depreciation_run(self, payload: dict[str, Any]) -> dict[str, Any]:
+ 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()}
+