Add recursive fixed asset discovery and exclude cash capital accounts

This commit is contained in:
A R R R Associates
2026-09-07 21:46:15 +05:30
parent 6d58ee82b0
commit 38f8242fb1
4 changed files with 152 additions and 24 deletions
+1 -1
View File
@@ -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)
@@ -1,2 +1,2 @@
__version__ = "1.26.3"
__version__ = "1.26.4"
AGENT_NAME = "ERP Local Agent"
@@ -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}
@@ -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),