diff --git a/app/modules/accounting/templates/accounting/depreciation_it.html b/app/modules/accounting/templates/accounting/depreciation_it.html index af56b2a..94676e0 100644 --- a/app/modules/accounting/templates/accounting/depreciation_it.html +++ b/app/modules/accounting/templates/accounting/depreciation_it.html @@ -36,7 +36,11 @@ {% 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 %} + {% if coverage.complete %} + Phase 4 transaction data covers the required period {{ coverage.required_from }} to {{ coverage.required_to }}{% if coverage.provisional %}. This is a provisional current-FY calculation; future dates are not required yet{% endif %}. + {% else %} + Phase 4 transaction data does not yet cover {{ coverage.required_from }} to {{ coverage.required_to }}. You may save a draft, but approval/write-back will remain blocked until the missing period is synchronized. + {% endif %}
@@ -50,11 +54,29 @@
-

Fixed Asset Ledgers

Rate is selected by the reviewer. Transaction date is used only as a review proxy for the 180-day put-to-use test; verify where actual put-to-use differs.

-
- {% for row in preview.fixed_asset_ledgers %}{% else %}{% endfor %} +
+

Fixed Asset Ledgers

+

Zero-opening ledgers with no current-period addition are omitted. Effective date is preferred for the 180-day test; voucher date is used when effective date is unavailable.

+
+ +
+ +
+
+
LedgerOpening WDVAdditions ≥180 daysAdditions <180 daysReductionsRate
{{ row.ledger_name }}
{{ row.parent_group }}
{{ '%.2f'|format(row.opening_wdv) }}{{ '%.2f'|format(row.additions_full_rate) }}{{ '%.2f'|format(row.additions_half_rate) }}{{ '%.2f'|format(row.reductions) }}
No ledgers under Fixed Assets were found in the synchronized master snapshot.
+ {% for row in preview.fixed_asset_ledgers %} + + + + + + + + + + {% else %}{% endfor %}
SelectLedgerOpening WDVAdditions ≥180 daysAdditions <180 daysReductionsRate
{{ row.ledger_name }}
{{ row.parent_group }}
{{ '%.2f'|format(row.opening_wdv) }}{{ '%.2f'|format(row.additions_full_rate) }}{{ '%.2f'|format(row.additions_half_rate) }}{{ '%.2f'|format(row.reductions) }}
No active Fixed Asset ledgers were found. Zero-opening ledgers without additions are intentionally omitted.
-
+
{% endif %} @@ -100,4 +122,30 @@
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/documents/agent_package.py b/app/modules/documents/agent_package.py index 300e939..910e897 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.7.1" +ERP_LOCAL_AGENT_VERSION = "1.8.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 7fcccc0..d33b8a3 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.7.1 +ERP Local Agent 1.8.0 Existing storage, WebSocket tunnel, Tally and client .act functionality are preserved. @@ -27,4 +27,11 @@ Phase 5 adds draft-only Income-tax depreciation calculations from synchronized . 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. -Hotfix 1.7.1: accepts phase6_status as a status alias; no accounting behavior changed. +Depreciation refinement 1.8.0: +- Accounting .act files default to \data\accounting, separate from document STORAGE_ROOT. +- Existing legacy \Accounting client databases are copied automatically on first use and left in place as recovery copies. +- Current-FY transaction coverage is required only through today, not through a future FY-end date. +- Draft calculation is available with incomplete coverage; approval/write-back remains blocked until required coverage is complete. +- Empty Fixed Asset ledgers are omitted unless they have current-period additions. +- Bulk checkbox + rate assignment is available. +- Nested Tally accounting allocations and case-insensitive ledger matching improve fixed-asset additions detection. 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 55fed7d..a96b81f 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.7.1" +__version__ = "1.8.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 f55d8fd..43626ee 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 @@ -2,7 +2,9 @@ from __future__ import annotations from datetime import date, datetime, timedelta, timezone import json +import os from pathlib import Path +import shutil import sqlite3 from typing import Sequence @@ -37,9 +39,32 @@ class LocalAccountingStore: """ def __init__(self, storage_root: Path): - self.root = Path(storage_root).resolve() / "Accounting" + # Accounting databases are application data, not document-storage payloads. + # Keep them under the Local Agent data directory by default. An explicit + # ACCOUNTING_ROOT may override this without changing document STORAGE_ROOT. + configured = str(os.getenv("ACCOUNTING_ROOT", "") or "").strip() + install_root = Path(__file__).resolve().parents[1] + self.root = Path(configured).expanduser().resolve() if configured else (install_root / "data" / "accounting").resolve() + self.legacy_root = Path(storage_root).resolve() / "Accounting" self.root.mkdir(parents=True, exist_ok=True) + def _migrate_legacy_client_if_needed(self, client_id: int) -> None: + key = self._client_key(client_id) + new_dir = self.root / key + new_db = new_dir / f"{key}.act" + old_dir = self.legacy_root / key + old_db = old_dir / f"{key}.act" + if new_db.exists() or not old_db.exists(): + return + new_dir.mkdir(parents=True, exist_ok=True) + # Copy, do not delete, the legacy database. The old copy remains a recovery + # fallback while all new reads/writes switch to Local Agent data/accounting. + shutil.copy2(old_db, new_db) + for suffix in ("-wal", "-shm"): + src = Path(str(old_db) + suffix) + if src.exists(): + shutil.copy2(src, Path(str(new_db) + suffix)) + @staticmethod def _client_key(client_id: int) -> str: return f"client_{int(client_id):08d}" @@ -48,13 +73,16 @@ class LocalAccountingStore: return self.root / self._client_key(client_id) def db_path(self, client_id: int) -> Path: + self._migrate_legacy_client_if_needed(client_id) key = self._client_key(client_id) return self.client_dir(client_id) / f"{key}.act" def exists(self, client_id: int) -> bool: + self._migrate_legacy_client_if_needed(client_id) return self.db_path(client_id).is_file() def connect(self, client_id: int): + self._migrate_legacy_client_if_needed(client_id) path = self.db_path(client_id) path.parent.mkdir(parents=True, exist_ok=True) db = sqlite3.connect(path, timeout=60) @@ -633,6 +661,8 @@ class LocalAccountingStore: return fixed def _transaction_coverage(self, db: sqlite3.Connection, tally_guid: str, fy_start: date, fy_end: date) -> dict: + today = date.today() + required_end = min(fy_end, today) 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,), @@ -642,8 +672,8 @@ class LocalAccountingStore: 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))) + if b >= fy_start and a <= required_end: + ranges.append((max(a, fy_start), min(b, required_end))) except Exception: continue ranges.sort() @@ -651,8 +681,23 @@ class LocalAccountingStore: 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]} + complete = bool( + required_end < fy_start + or ( + merged + and merged[0][0] <= fy_start + and merged[-1][1] >= required_end + and all(merged[i][1] + timedelta(days=1) >= merged[i+1][0] for i in range(len(merged)-1)) + ) + ) + return { + "complete": bool(complete), + "ranges": [{"from":a.isoformat(),"to":b.isoformat()} for a,b in merged], + "required_from": fy_start.isoformat(), + "required_to": required_end.isoformat(), + "financial_year_end": fy_end.isoformat(), + "provisional": fy_end > today, + } def depreciation_preview(self, client_id: int, *, tally_guid: str, fy_start: str, fy_end: str) -> dict: if not self.exists(client_id): @@ -676,26 +721,42 @@ class LocalAccountingStore: 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() + movement_end = min(end, date.today()) entries=db.execute( - """SELECT v.voucher_date, e.amount, e.is_deemed_positive + """SELECT COALESCE(NULLIF(v.effective_date,''), v.voucher_date) AS movement_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<=? + WHERE v.tally_guid=? + AND lower(trim(COALESCE(e.ledger_name,'')))=lower(trim(?)) + AND COALESCE(NULLIF(v.effective_date,''), v.voucher_date)>=? + AND COALESCE(NULLIF(v.effective_date,''), 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()), + (tally_guid,name,start.isoformat(),movement_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 + if self._half_rate_addition(str(e["movement_date"] or ""), end): half += amt else: full += amt else: reductions += amt + opening_wdv = abs(float(row["opening_balance"] or 0)) + tally_closing = abs(float(row["closing_balance"] or 0)) + additions_total = round(full + half, 2) + + # Do not clutter the depreciation schedule with empty Fixed Asset + # ledgers. A zero-opening ledger remains visible only when a current- + # period addition has actually been identified. + if opening_wdv == 0 and additions_total == 0: + continue + 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)), + "opening_wdv":opening_wdv,"tally_closing_balance":tally_closing, "additions_full_rate":round(full,2),"additions_half_rate":round(half,2),"reductions":round(reductions,2), "rate_percent":rules.get(key,0.0), + "movement_entries":len(entries), }) 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() @@ -703,8 +764,9 @@ class LocalAccountingStore: 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.") + # A draft may be calculated even when transaction coverage is incomplete so + # the reviewer can work progressively. Approval/write-back remains blocked + # until Phase 4 covers the required period. 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] @@ -727,7 +789,7 @@ class LocalAccountingStore: 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} + details={"calculation_basis":"Ledger-level draft using synchronized opening balance and voucher movements; effective date is preferred, otherwise voucher date is used as the review proxy for the 180-day put-to-use test.","transaction_coverage":preview["transaction_coverage"],"provisional":bool(preview["transaction_coverage"].get("provisional")),"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=(",",":")))) @@ -762,6 +824,14 @@ class LocalAccountingStore: 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.") + try: + run_details = json.loads(run["details_json"] or "{}") + except Exception: + run_details = {} + coverage = run_details.get("transaction_coverage") or {} + if not coverage.get("complete"): + required_to = str(coverage.get("required_to") or "the required period") + raise ValueError(f"Phase 4 transactions must be synchronized through {required_to} before this draft can be approved.") 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( 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 1e4df9d..b266554 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 @@ -382,7 +382,20 @@ class TallyLiveConnector: ledger_entries: list[dict] = [] inventory_entries: list[dict] = [] - for entry in _direct_children(voucher, ledger_tags): + # Tally can emit ledger allocations directly under VOUCHER or nested + # inside invoice/inventory structures. Read both forms once. + ledger_nodes = [] + seen_ledger_nodes = set() + for entry in voucher.iter(): + if entry is voucher or _tag(entry) not in (ledger_tags | {"ACCOUNTINGALLOCATIONS.LIST"}): + continue + marker = id(entry) + if marker in seen_ledger_nodes: + continue + seen_ledger_nodes.add(marker) + ledger_nodes.append(entry) + + for entry in ledger_nodes: bills = [] banks = [] cost_centres = []