Add recursive fixed asset discovery and exclude cash capital accounts
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user