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
|
from pathlib import Path
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
ERP_LOCAL_AGENT_VERSION = "1.26.3"
|
ERP_LOCAL_AGENT_VERSION = "1.26.4"
|
||||||
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
ERP_LOCAL_AGENT_NAME = "ERP Local Agent"
|
||||||
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime"
|
||||||
_DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
|
_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"
|
AGENT_NAME = "ERP Local Agent"
|
||||||
|
|||||||
@@ -921,18 +921,27 @@ class LocalAccountingStore:
|
|||||||
return any(token in value for token in keywords)
|
return any(token in value for token in keywords)
|
||||||
|
|
||||||
def _fixed_asset_group_names(self, db: sqlite3.Connection, tally_guid: str) -> set[str]:
|
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(
|
rows = db.execute(
|
||||||
"SELECT name, parent FROM tally_groups WHERE tally_guid=?",
|
"SELECT name, parent FROM tally_groups WHERE tally_guid=?",
|
||||||
(tally_guid,),
|
(tally_guid,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
all_groups = {
|
all_groups = {
|
||||||
str(r["name"] or "").strip(): str(r["parent"] or "").strip()
|
str(r["name"] or "").strip(): str(r["parent"] or "").strip()
|
||||||
for r in rows
|
for r in rows if str(r["name"] or "").strip()
|
||||||
if str(r["name"] or "").strip()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Preferred path: full Tally group hierarchy is available.
|
fixed = {
|
||||||
fixed = {name for name in all_groups if name.casefold() == "fixed assets"}
|
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
|
changed = True
|
||||||
while changed:
|
while changed:
|
||||||
changed = False
|
changed = False
|
||||||
@@ -941,22 +950,26 @@ class LocalAccountingStore:
|
|||||||
if name not in fixed and parent.casefold() in fixed_fold:
|
if name not in fixed and parent.casefold() in fixed_fold:
|
||||||
fixed.add(name)
|
fixed.add(name)
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if fixed:
|
if fixed:
|
||||||
return fixed
|
return fixed
|
||||||
|
|
||||||
# Proven ODBC mirror fallback: group_master may intentionally be absent,
|
# Older mirrors may not expose Group Master. Keep the conservative
|
||||||
# but every mirrored ledger still carries its parent group.
|
# immediate-parent / ledger-name fallback for those installations.
|
||||||
ledger_parents = db.execute(
|
ledger_rows = db.execute(
|
||||||
"""SELECT DISTINCT TRIM(COALESCE(parent,'')) AS parent
|
"""SELECT DISTINCT TRIM(COALESCE(name,'')) AS name,
|
||||||
FROM tally_ledgers
|
TRIM(COALESCE(parent,'')) AS parent
|
||||||
WHERE tally_guid=? AND TRIM(COALESCE(parent,''))<>''""",
|
FROM tally_ledgers WHERE tally_guid=?""",
|
||||||
(tally_guid,),
|
(tally_guid,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {
|
result = set()
|
||||||
str(row["parent"] or "").strip()
|
for row in ledger_rows:
|
||||||
for row in ledger_parents
|
name = str(row["name"] or "").strip()
|
||||||
if self._looks_like_fixed_asset_group(str(row["parent"] or ""))
|
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:
|
def _transaction_coverage(self, db: sqlite3.Connection, tally_guid: str, fy_start: date, fy_end: date) -> dict:
|
||||||
today = date.today()
|
today = date.today()
|
||||||
@@ -1095,8 +1108,6 @@ class LocalAccountingStore:
|
|||||||
if start > end: raise ValueError("Financial-year start cannot be after end date.")
|
if start > end: raise ValueError("Financial-year start cannot be after end date.")
|
||||||
with self.connect(client_id) as db:
|
with self.connect(client_id) as db:
|
||||||
fixed_groups = self._fixed_asset_group_names(db, tally_guid)
|
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(
|
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",
|
"SELECT master_guid, name, parent, opening_balance, closing_balance, payload_json FROM tally_ledgers WHERE tally_guid=? ORDER BY name COLLATE NOCASE",
|
||||||
(tally_guid,),
|
(tally_guid,),
|
||||||
@@ -1107,8 +1118,21 @@ class LocalAccountingStore:
|
|||||||
fixed_fold = {x.casefold() for x in fixed_groups}
|
fixed_fold = {x.casefold() for x in fixed_groups}
|
||||||
result=[]
|
result=[]
|
||||||
for row in ledgers:
|
for row in ledgers:
|
||||||
if str(row["parent"] or "").strip().casefold() not in fixed_fold: continue
|
parent_name = str(row["parent"] or "").strip()
|
||||||
item=dict(row); key=self._ledger_key(item); name=str(row["name"] 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())
|
movement_end = min(end, date.today())
|
||||||
entries=db.execute(
|
entries=db.execute(
|
||||||
"""SELECT COALESCE(NULLIF(v.effective_date,''), v.voucher_date) AS movement_date,
|
"""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),
|
"rate_percent":rules.get(key,0.0),
|
||||||
"movement_entries":len(entries),
|
"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()]
|
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()
|
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}
|
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(
|
ledger_rows = db.execute(
|
||||||
"SELECT ledger_name, parent_group FROM ledger_master ORDER BY ledger_name"
|
"SELECT ledger_name, parent_group FROM ledger_master ORDER BY ledger_name"
|
||||||
).fetchall()
|
).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] = []
|
cash_ledger_names: list[str] = []
|
||||||
for row in ledger_rows:
|
for row in ledger_rows:
|
||||||
name = str(row["ledger_name"] or "").strip()
|
name = str(row["ledger_name"] or "").strip()
|
||||||
@@ -2609,6 +2685,7 @@ class AgentCommandProcessor:
|
|||||||
})
|
})
|
||||||
|
|
||||||
payments: list[dict[str, Any]] = []
|
payments: list[dict[str, Any]] = []
|
||||||
|
capital_vouchers_excluded = 0
|
||||||
for voucher in grouped.values():
|
for voucher in grouped.values():
|
||||||
cash_credit: list[float] = []
|
cash_credit: list[float] = []
|
||||||
for entry in voucher["entries"]:
|
for entry in voucher["entries"]:
|
||||||
@@ -2625,10 +2702,30 @@ class AgentCommandProcessor:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
non_cash = [e for e in voucher["entries"] if e["ledger_name"].casefold() not in cash_keys]
|
non_cash = [e for e in voucher["entries"] if e["ledger_name"].casefold() not in cash_keys]
|
||||||
party = str(voucher["party"] or "").strip()
|
stated_party = str(voucher["party"] or "").strip()
|
||||||
if not party or party.casefold() in cash_keys:
|
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(
|
candidates = sorted(
|
||||||
non_cash,
|
eligible_non_cash,
|
||||||
key=lambda e: abs(float(e["raw_amount"] or e["amount"] or 0)),
|
key=lambda e: abs(float(e["raw_amount"] or e["amount"] or 0)),
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
@@ -2737,6 +2834,7 @@ class AgentCommandProcessor:
|
|||||||
"date_to": date_to,
|
"date_to": date_to,
|
||||||
"cash_limit": cash_limit,
|
"cash_limit": cash_limit,
|
||||||
"cash_ledgers": cash_ledger_names,
|
"cash_ledgers": cash_ledger_names,
|
||||||
|
"capital_equity_vouchers_excluded": int(capital_vouchers_excluded),
|
||||||
"summary": {
|
"summary": {
|
||||||
"vouchers_reviewed": total_vouchers,
|
"vouchers_reviewed": total_vouchers,
|
||||||
"cash_payment_vouchers": len(payments),
|
"cash_payment_vouchers": len(payments),
|
||||||
|
|||||||
Reference in New Issue
Block a user