diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 03b8fad..f2553e6 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.26.3" +ERP_LOCAL_AGENT_VERSION = "1.26.4" 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/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 1a14052..d1281e5 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.26.3" +__version__ = "1.26.4" 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 e8dc800..7e5a068 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 @@ -921,18 +921,27 @@ class LocalAccountingStore: return any(token in value for token in keywords) def _fixed_asset_group_names(self, db: sqlite3.Connection, tally_guid: str) -> set[str]: + """Return Fixed-Asset groups and every descendant at any nesting depth.""" 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 - if str(r["name"] or "").strip() + for r in rows if str(r["name"] or "").strip() } - # Preferred path: full Tally group hierarchy is available. - fixed = {name for name in all_groups if name.casefold() == "fixed assets"} + fixed = { + name for name in all_groups + if name.casefold() in {"fixed assets", "fixed asset"} + } + if not fixed and all_groups: + fixed = { + name for name in all_groups + if self._looks_like_fixed_asset_group(name) + } + + # Recursive closure: subgroup -> sub-subgroup -> any later depth. changed = True while changed: changed = False @@ -941,22 +950,26 @@ class LocalAccountingStore: if name not in fixed and parent.casefold() in fixed_fold: fixed.add(name) changed = True + if fixed: return fixed - # Proven ODBC mirror fallback: group_master may intentionally be absent, - # but every mirrored ledger still carries its parent group. - ledger_parents = db.execute( - """SELECT DISTINCT TRIM(COALESCE(parent,'')) AS parent - FROM tally_ledgers - WHERE tally_guid=? AND TRIM(COALESCE(parent,''))<>''""", + # Older mirrors may not expose Group Master. Keep the conservative + # immediate-parent / ledger-name fallback for those installations. + ledger_rows = db.execute( + """SELECT DISTINCT TRIM(COALESCE(name,'')) AS name, + TRIM(COALESCE(parent,'')) AS parent + FROM tally_ledgers WHERE tally_guid=?""", (tally_guid,), ).fetchall() - return { - str(row["parent"] or "").strip() - for row in ledger_parents - if self._looks_like_fixed_asset_group(str(row["parent"] or "")) - } + result = set() + for row in ledger_rows: + name = str(row["name"] or "").strip() + parent = str(row["parent"] or "").strip() + if self._looks_like_fixed_asset_group(parent) or self._looks_like_fixed_asset_group(name): + if parent: + result.add(parent) + return result def _transaction_coverage(self, db: sqlite3.Connection, tally_guid: str, fy_start: date, fy_end: date) -> dict: today = date.today() @@ -1095,8 +1108,6 @@ class LocalAccountingStore: 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("No Fixed Asset ledger/group could be identified in the local Accounting Mirror. Verify that fixed-asset ledgers are grouped under Fixed Assets (or a standard fixed-asset subgroup) in Tally, then refresh the mirror.") 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,), @@ -1107,8 +1118,21 @@ class LocalAccountingStore: 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() + parent_name = str(row["parent"] or "").strip() + ledger_name = str(row["name"] or "").strip() + is_fixed_asset = ( + parent_name.casefold() in fixed_fold + or ( + not fixed_fold + and ( + self._looks_like_fixed_asset_group(parent_name) + or self._looks_like_fixed_asset_group(ledger_name) + ) + ) + ) + if not is_fixed_asset: + continue + item=dict(row); key=self._ledger_key(item); name=ledger_name movement_end = min(end, date.today()) entries=db.execute( """SELECT COALESCE(NULLIF(v.effective_date,''), v.voucher_date) AS movement_date, @@ -1157,6 +1181,12 @@ class LocalAccountingStore: "rate_percent":rules.get(key,0.0), "movement_entries":len(entries), }) + if not result: + raise ValueError( + "No Fixed Asset ledger could be identified in the local Accounting Mirror. " + "The depreciation engine supports Fixed Assets at any subgroup depth. " + "Refresh the mirror once so the latest Tally Group Master hierarchy is available." + ) 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} 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 3a66bca..bff1c71 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 @@ -2515,6 +2515,82 @@ class AgentCommandProcessor: ledger_rows = db.execute( "SELECT ledger_name, parent_group FROM ledger_master ORDER BY ledger_name" ).fetchall() + + ledger_parent = { + str(row["ledger_name"] or "").strip().casefold(): + str(row["parent_group"] or "").strip() + for row in ledger_rows if str(row["ledger_name"] or "").strip() + } + + group_parent: dict[str, str] = {} + try: + group_rows = db.execute("SELECT name, parent FROM group_master").fetchall() + group_parent = { + str(row["name"] or "").strip().casefold(): + str(row["parent"] or "").strip() + for row in group_rows if str(row["name"] or "").strip() + } + except Exception: + group_parent = {} + + def _normal_group(value: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", str(value or "").casefold()).strip() + + def _capital_equity_name(value: str) -> bool: + text = _normal_group(value) + if not text: + return False + exact = { + "capital account", "capital accounts", "share capital", + "reserves surplus", "reserves and surplus", + "partners capital", "partner capital", + "proprietors capital", "proprietor capital", + "owners capital", "owner capital", + } + if text in exact: + return True + owner_word = any( + token in text.split() + for token in ("partner","partners","proprietor","proprietors","owner","owners") + ) + current_like = ( + "current account" in text + or "current accounts" in text + or "current a c" in text + ) + return bool( + text.startswith("capital account") + or text.endswith("capital account") + or text.endswith("capital accounts") + or (owner_word and current_like) + ) + + def _group_is_capital_or_descendant(group_name: str) -> bool: + current = str(group_name or "").strip() + seen: set[str] = set() + for _ in range(64): + if not current: + return False + if _capital_equity_name(current): + return True + key = current.casefold() + if key in seen: + return False + seen.add(key) + current = group_parent.get(key, "") + return False + + def _ledger_is_capital_equity(ledger_name: str) -> bool: + name = str(ledger_name or "").strip() + if not name: + return False + parent = ledger_parent.get(name.casefold(), "") + return bool( + _capital_equity_name(name) + or _capital_equity_name(parent) + or _group_is_capital_or_descendant(parent) + ) + cash_ledger_names: list[str] = [] for row in ledger_rows: name = str(row["ledger_name"] or "").strip() @@ -2609,6 +2685,7 @@ class AgentCommandProcessor: }) payments: list[dict[str, Any]] = [] + capital_vouchers_excluded = 0 for voucher in grouped.values(): cash_credit: list[float] = [] for entry in voucher["entries"]: @@ -2625,10 +2702,30 @@ class AgentCommandProcessor: continue non_cash = [e for e in voucher["entries"] if e["ledger_name"].casefold() not in cash_keys] - party = str(voucher["party"] or "").strip() - if not party or party.casefold() in cash_keys: + stated_party = str(voucher["party"] or "").strip() + material_non_cash = [ + e for e in non_cash + if abs(float(e["raw_amount"] or e["amount"] or 0)) > 0.009 + ] + + if stated_party and _ledger_is_capital_equity(stated_party): + capital_vouchers_excluded += 1 + continue + if material_non_cash and all( + _ledger_is_capital_equity(e["ledger_name"]) + for e in material_non_cash + ): + capital_vouchers_excluded += 1 + continue + + eligible_non_cash = [ + e for e in non_cash + if not _ledger_is_capital_equity(e["ledger_name"]) + ] + party = stated_party + if not party or party.casefold() in cash_keys or _ledger_is_capital_equity(party): candidates = sorted( - non_cash, + eligible_non_cash, key=lambda e: abs(float(e["raw_amount"] or e["amount"] or 0)), reverse=True, ) @@ -2737,6 +2834,7 @@ class AgentCommandProcessor: "date_to": date_to, "cash_limit": cash_limit, "cash_ledgers": cash_ledger_names, + "capital_equity_vouchers_excluded": int(capital_vouchers_excluded), "summary": { "vouchers_reviewed": total_vouchers, "cash_payment_vouchers": len(payments),