From a1828d645b103a127e26b64ed349fe98f4e6b4d9 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Wed, 9 Sep 2026 16:55:15 +0530 Subject: [PATCH] Fix sundry creditors FIFO ageing from mirror voucher dates --- .../accounting/accounting_mirror_service.py | 230 ++++++++++++------ app/modules/documents/agent_package.py | 2 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/commands.py | 80 +++++- 4 files changed, 227 insertions(+), 87 deletions(-) diff --git a/app/modules/accounting/accounting_mirror_service.py b/app/modules/accounting/accounting_mirror_service.py index b7e6b85..e387605 100644 --- a/app/modules/accounting/accounting_mirror_service.py +++ b/app/modules/accounting/accounting_mirror_service.py @@ -268,24 +268,26 @@ def sundry_creditors_aging( ) -> dict[str, Any]: """Age closing Sundry Creditors using FIFO from the Accounting Mirror. - FIFO means debit-side settlements consume the oldest creditor balance first. - At a reporting date, the unpaid closing balance is therefore represented by - the *latest* credit-side additions, with any residual carried from opening. + The selected-FY closing is reconstructed from the mirror's ledger opening + balance plus all creditor movements available up to the selected FY end. + FIFO means the oldest credit lots are settled first; therefore the closing + unpaid balance is reconstructed from the newest surviving credit lots. - We first reconstruct the selected FY closing balance from the mirror opening - balance and selected-period movements, then reconstruct its unpaid composition - backwards. This avoids using ledger_master.closing_balance for a historical FY - because that master value can reflect a different/current reporting period. - - Tally bill-allocation references are not yet persisted in the .act mirror, so - this remains a transparent FIFO analysis rather than Agst Ref/New Ref matching. + Voucher dates are normalized before ageing. This is intentionally strict: + a dated movement that cannot be parsed is not silently treated as an old + opening balance, because doing so would incorrectly push current balances + into the >180-day bucket. """ - from datetime import date as _date, timedelta as _timedelta + from datetime import date as _date, datetime as _datetime, timedelta as _timedelta + # Pull everything available up to the selected FY end. This lets a mirror + # containing more than one FY retain the original date of an older surviving + # creditor lot. If the mirror starts at the selected FY, any unrepresented + # residual is treated as brought forward and is necessarily >180 days at FY end. current = _creditor_movements( node_code=node_code, accounting_payload=accounting_payload, - from_date=fy_start, + from_date="", to_date=fy_end, limit=limit, ) @@ -304,15 +306,31 @@ def sundry_creditors_aging( "Sundry Creditor movement extraction reached the safety limit. " "Increase the mirror query limit before relying on this analysis." ) + if int(current.get("date_parse_error_count") or 0) > 0: + sample = (current.get("date_parse_errors") or [{}])[0] + raise AccountingMirrorError( + "Accounting Mirror contains creditor voucher date(s) that cannot be normalized. " + f"First affected entry: {sample.get('ledger_name') or 'Unknown ledger'} / " + f"{sample.get('voucher_number') or 'No voucher number'} / " + f"{sample.get('voucher_date') or 'blank date'}. Refresh the Accounting Mirror and retry." + ) if later.get("truncated"): raise AccountingMirrorError( "Follow-up Sundry Creditor movement extraction reached the safety limit. " "Increase the mirror query limit before relying on the subsequent-payment analysis." ) + if int(later.get("date_parse_error_count") or 0) > 0: + sample = (later.get("date_parse_errors") or [{}])[0] + raise AccountingMirrorError( + "Follow-up Accounting Mirror contains creditor voucher date(s) that cannot be normalized. " + f"First affected entry: {sample.get('ledger_name') or 'Unknown ledger'} / " + f"{sample.get('voucher_number') or 'No voucher number'} / " + f"{sample.get('voucher_date') or 'blank date'}. Refresh the follow-up Accounting Mirror and retry." + ) fy_start_date = _date.fromisoformat(fy_start) fy_end_date = _date.fromisoformat(fy_end) - opening_date = fy_start_date - _timedelta(days=1) + opening_fallback_date = fy_start_date - _timedelta(days=1) def _money(value: Any) -> float: try: @@ -320,8 +338,35 @@ def sundry_creditors_aging( except (TypeError, ValueError): return 0.0 - def _row_date(row: dict[str, Any]) -> str: - return str(row.get("voucher_date") or "").strip() + def _parse_date(value: Any) -> _date | None: + text = str(value or "").strip() + if not text: + return None + # Fast path for the current .act ISO format. + try: + return _date.fromisoformat(text[:10]) + except ValueError: + pass + candidates = ( + "%Y%m%d", + "%d-%m-%Y", + "%d/%m/%Y", + "%d-%b-%Y", + "%d-%b-%y", + "%m/%d/%Y", + "%m/%d/%Y %H:%M:%S", + "%Y-%m-%d %H:%M:%S", + ) + for fmt in candidates: + try: + return _datetime.strptime(text, fmt).date() + except ValueError: + continue + return None + + def _iso_date(value: Any) -> str: + parsed = _parse_date(value) + return parsed.isoformat() if parsed else "" ledger_meta = { str(x.get("ledger_name") or "").casefold(): x @@ -330,16 +375,60 @@ def sundry_creditors_aging( } by_ledger: dict[str, list[dict[str, Any]]] = {} - for row in current.get("rows") or []: + invalid_dates: list[dict[str, str]] = [] + for raw_row in current.get("rows") or []: + row = dict(raw_row) name = str(row.get("ledger_name") or "").strip() - if name: - by_ledger.setdefault(name.casefold(), []).append(row) + if not name: + continue + parsed = _parse_date(row.get("voucher_date")) + if parsed is None: + invalid_dates.append({ + "party": name, + "voucher_number": str(row.get("voucher_number") or ""), + "voucher_date": str(row.get("voucher_date") or ""), + }) + continue + if parsed > fy_end_date: + continue + row["voucher_date"] = parsed.isoformat() + row["_parsed_date"] = parsed + by_ledger.setdefault(name.casefold(), []).append(row) + + if invalid_dates: + first = invalid_dates[0] + raise AccountingMirrorError( + "Accounting Mirror contains creditor voucher date(s) that could not be parsed. " + f"First affected entry: {first['party']} / {first['voucher_number']} / " + f"{first['voucher_date'] or 'blank date'}. Refresh the Accounting Mirror and retry." + ) later_by_ledger: dict[str, list[dict[str, Any]]] = {} - for row in later.get("rows") or []: + invalid_later_dates: list[dict[str, str]] = [] + for raw_row in later.get("rows") or []: + row = dict(raw_row) name = str(row.get("ledger_name") or "").strip() - if name: - later_by_ledger.setdefault(name.casefold(), []).append(row) + if not name: + continue + parsed = _parse_date(row.get("voucher_date")) + if parsed is None: + invalid_later_dates.append({ + "party": name, + "voucher_number": str(row.get("voucher_number") or ""), + "voucher_date": str(row.get("voucher_date") or ""), + }) + continue + row["voucher_date"] = parsed.isoformat() + row["_parsed_date"] = parsed + later_by_ledger.setdefault(name.casefold(), []).append(row) + + if invalid_later_dates: + first = invalid_later_dates[0] + raise AccountingMirrorError( + "Follow-up Accounting Mirror contains creditor voucher date(s) that could not be parsed. " + f"First affected entry: {first['party']} / {first['voucher_number']} / " + f"{first['voucher_date'] or 'blank date'}. Refresh the follow-up Accounting Mirror and retry." + ) parties: list[dict[str, Any]] = [] details: list[dict[str, Any]] = [] @@ -356,45 +445,44 @@ def sundry_creditors_aging( for party_name in party_names: key = party_name.casefold() meta = ledger_meta.get(key) or {} + rows = sorted( + by_ledger.get(key, []), + key=lambda r: (r.get("_parsed_date"), int(r.get("line_no") or 0)), + ) - rows = by_ledger.get(key, []) - - # IMPORTANT: ledger_master.closing_balance is a master-level snapshot and - # is not reliable as the closing balance for an arbitrary historical FY. - # Reconstruct the selected FY closing from the mirror's opening balance and - # the selected-period creditor movements. This is the same balance control - # used by the first mirror implementation and keeps historical FY analysis - # tied to the voucher stream rather than the current ledger-master closing. + # Preserve the selected-FY closing-balance control already validated in v2: + # ledger opening + movements inside the selected FY. Earlier rows, when the + # mirror has them, are used only to identify the original date/composition of + # brought-forward FIFO lots; they are not added again to the closing balance. opening_balance = _money(meta.get("opening_balance")) + fy_rows = [ + row for row in rows + if fy_start_date <= row.get("_parsed_date") <= fy_end_date + ] period_credit = round(sum( - _money(row.get("amount")) for row in rows + _money(row.get("amount")) for row in fy_rows if str(row.get("dr_cr") or "").upper() == "CR" ), 2) period_debit = round(sum( - _money(row.get("amount")) for row in rows + _money(row.get("amount")) for row in fy_rows if str(row.get("dr_cr") or "").upper() == "DR" ), 2) historical_closing = round(opening_balance + period_credit - period_debit, 2) - - # Only credit closing balances are Sundry Creditors for this report. closing_balance = max(0.0, historical_closing) if closing_balance <= 0.009: continue credit_rows = [ row for row in rows - if str(row.get("dr_cr") or "").upper() == "CR" and _money(row.get("amount")) > 0.009 + if str(row.get("dr_cr") or "").upper() == "CR" + and _money(row.get("amount")) > 0.009 ] - # Reverse reconstruction of the selected FY closing balance under FIFO: - # oldest balances are settled first, therefore the newest credits remain. + # FIFO reverse reconstruction. FIFO settlements clear oldest balances, + # so the newest credit lots are the ones that survive at the reporting date. amount_to_allocate = closing_balance lots_newest_first: list[dict[str, Any]] = [] - for row in sorted( - credit_rows, - key=lambda r: (_row_date(r), int(r.get("line_no") or 0)), - reverse=True, - ): + for row in reversed(credit_rows): if amount_to_allocate <= 0.009: break source_amount = _money(row.get("amount")) @@ -404,7 +492,7 @@ def sundry_creditors_aging( lots_newest_first.append({ "party_name": party_name, "source": "Voucher", - "voucher_date": _row_date(row), + "voucher_date": str(row.get("voucher_date") or ""), "voucher_type": str(row.get("voucher_type") or ""), "voucher_number": str(row.get("voucher_number") or ""), "reference": str(row.get("reference") or ""), @@ -416,13 +504,13 @@ def sundry_creditors_aging( }) amount_to_allocate = round(amount_to_allocate - allocated, 2) - # Anything not represented by current-year credits is necessarily a - # brought-forward closing component and is old at this FY close. + # If the closing cannot be fully represented by dated credit rows available + # in this mirror, the residual is a genuine brought-forward component. if amount_to_allocate > 0.009: lots_newest_first.append({ "party_name": party_name, "source": "Opening / brought forward", - "voucher_date": opening_date.isoformat(), + "voucher_date": opening_fallback_date.isoformat(), "voucher_type": "Opening", "voucher_number": "", "reference": "", @@ -432,14 +520,13 @@ def sundry_creditors_aging( "paid_subsequently": 0.0, "final_payment_date": "", }) - amount_to_allocate = 0.0 - # FIFO settlement in the follow-up period must consume the oldest FY-end - # lots first. Later credits are new liabilities and do not change which - # FY-end lot a FIFO debit settles. closing_lots = sorted( lots_newest_first, - key=lambda lot: (str(lot.get("voucher_date") or ""), str(lot.get("voucher_number") or "")), + key=lambda lot: ( + _parse_date(lot.get("voucher_date")) or opening_fallback_date, + str(lot.get("voucher_number") or ""), + ), ) def settle_follow_up(amount: float, paid_on: str) -> None: @@ -459,10 +546,10 @@ def sundry_creditors_aging( for row in sorted( later_by_ledger.get(key, []), - key=lambda r: (_row_date(r), int(r.get("line_no") or 0)), + key=lambda r: (r.get("_parsed_date"), int(r.get("line_no") or 0)), ): if str(row.get("dr_cr") or "").upper() == "DR": - settle_follow_up(_money(row.get("amount")), _row_date(row)) + settle_follow_up(_money(row.get("amount")), str(row.get("voucher_date") or "")) within_180 = 0.0 over_180 = 0.0 @@ -471,12 +558,8 @@ def sundry_creditors_aging( over_180_lots: list[dict[str, Any]] = [] for lot in closing_lots: - try: - lot_date = _date.fromisoformat(str(lot.get("voucher_date") or "")) - except ValueError: - # A malformed/missing date must not be classified as current. - lot_date = opening_date - age_days = (fy_end_date - lot_date).days + lot_date = _parse_date(lot.get("voucher_date")) or opening_fallback_date + age_days = max(0, (fy_end_date - lot_date).days) bucket = ">180 Days" if age_days > 180 else "≤180 Days" outstanding = _money(lot.get("outstanding_at_fy_end")) paid = round(min(outstanding, _money(lot.get("paid_subsequently"))), 2) @@ -491,30 +574,33 @@ def sundry_creditors_aging( within_180 = round(within_180 + outstanding, 2) details.append({ - **{ - k: lot.get(k) - for k in ( - "party_name", "source", "voucher_date", "voucher_type", - "voucher_number", "reference", "original_credit", "final_payment_date", - ) - }, + "party_name": lot.get("party_name"), + "source": lot.get("source"), + "voucher_date": lot.get("voucher_date"), + "voucher_type": lot.get("voucher_type"), + "voucher_number": lot.get("voucher_number"), + "reference": lot.get("reference"), + "original_credit": lot.get("original_credit"), "outstanding_at_fy_end": outstanding, "age_days": age_days, "age_bucket": bucket, "paid_subsequently": paid, "balance_after_follow_up": after, - "allocation_basis": "FIFO from selected-FY Accounting Mirror movements", + "final_payment_date": lot.get("final_payment_date"), + "allocation_basis": "FIFO from dated Accounting Mirror creditor movements", }) - # Date on which the party's >180 FY-end component became fully paid. final_payment_date = "" if over_180_lots and all(_money(lot.get("remaining")) <= 0.009 for lot in over_180_lots): - dates = [str(lot.get("final_payment_date") or "") for lot in over_180_lots if lot.get("final_payment_date")] + dates = [ + str(lot.get("final_payment_date") or "") + for lot in over_180_lots + if lot.get("final_payment_date") + ] if dates: final_payment_date = max(dates) reconstructed = round(within_180 + over_180, 2) - # Maintain the selected-FY movement-derived closing balance as the control. variance = round(closing_balance - reconstructed, 2) if abs(variance) > 0.01: raise AccountingMirrorError( @@ -545,9 +631,9 @@ def sundry_creditors_aging( "parties": parties, "details": details, "allocation_basis": ( - "FIFO from selected-FY Accounting Mirror movements; the historical " - "closing balance is reconstructed from opening balance plus FY credits " - "less FY debits, and newest credit-side additions form the unpaid closing balance" + "FIFO from dated Accounting Mirror movements; historical closing is " + "reconstructed from ledger opening plus selected-FY movements, " + "and the newest surviving credit lots form the unpaid closing balance" ), "current_mirror": current.get("mirror") or {}, "follow_up_mirror": later.get("mirror") or {}, diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index a14a06e..134e31a 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.11" +ERP_LOCAL_AGENT_VERSION = "1.26.12" 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 3ea7852..882dae5 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.11" +__version__ = "1.26.12" AGENT_NAME = "ERP Local Agent" 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 f48eda9..45b3325 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 @@ -4224,24 +4224,76 @@ class AgentCommandProcessor: names = [str(x.get("ledger_name") or "").strip() for x in ledgers if str(x.get("ledger_name") or "").strip()] rows = [] + date_parse_errors = [] + truncated = False if names: + from datetime import datetime as _dt + + def _iso_voucher_date(value): + text = str(value or "").strip() + if not text: + return "" + try: + return _dt.fromisoformat(text[:10]).date().isoformat() + except ValueError: + pass + for fmt in ( + "%Y%m%d", "%d-%m-%Y", "%d/%m/%Y", "%d-%b-%Y", + "%d-%b-%y", "%m/%d/%Y", "%m/%d/%Y %H:%M:%S", + "%Y-%m-%d %H:%M:%S", + ): + try: + return _dt.strptime(text, fmt).date().isoformat() + except ValueError: + continue + return "" + placeholders = ",".join("?" for _ in names) - clauses = [f"LOWER(ledger_name) IN ({placeholders})"] - params = [x.casefold() for x in names] - if from_date: - clauses.append("voucher_date>=?") - params.append(from_date) - if to_date: - clauses.append("voucher_date<=?") - params.append(to_date) + # Do not filter dates in SQL here. Older .act files can contain + # Tally/ODBC date representations that are valid dates but are not + # lexically comparable with YYYY-MM-DD. Normalize in Python first, + # then apply the requested date window. + scan_limit = max(100000, min(250000, creditor_limit * 2)) sql = ( "SELECT voucher_guid,line_no,voucher_date,voucher_type,voucher_number,party_ledger," "ledger_name,dr_cr,amount,raw_amount FROM voucher_ledger_entry WHERE " - + " AND ".join(clauses) - + " ORDER BY ledger_name,voucher_date,line_no LIMIT ?" + f"LOWER(ledger_name) IN ({placeholders}) " + "ORDER BY ledger_name,line_no LIMIT ?" ) - params.append(creditor_limit) - rows = [dict(r) for r in db.execute(sql, tuple(params)).fetchall()] + raw_rows = [dict(r) for r in db.execute( + sql, + tuple([x.casefold() for x in names] + [scan_limit + 1]), + ).fetchall()] + if len(raw_rows) > scan_limit: + truncated = True + raw_rows = raw_rows[:scan_limit] + + for row in raw_rows: + original_date = str(row.get("voucher_date") or "").strip() + normalized_date = _iso_voucher_date(original_date) + if not normalized_date: + date_parse_errors.append({ + "ledger_name": str(row.get("ledger_name") or ""), + "voucher_number": str(row.get("voucher_number") or ""), + "voucher_date": original_date, + }) + continue + if from_date and normalized_date < from_date: + continue + if to_date and normalized_date > to_date: + continue + row["voucher_date"] = normalized_date + rows.append(row) + + rows.sort(key=lambda r: ( + str(r.get("ledger_name") or "").casefold(), + str(r.get("voucher_date") or ""), + int(r.get("line_no") or 0), + )) + if len(rows) > creditor_limit: + truncated = True + rows = rows[:creditor_limit] + # Voucher reference/narration are intentionally joined from the # voucher header so the exported audit trail remains readable. if rows: @@ -4263,7 +4315,9 @@ class AgentCommandProcessor: "ledgers": ledgers, "rows": rows, "count": len(rows), - "truncated": len(rows) >= creditor_limit, + "truncated": truncated, + "date_parse_errors": date_parse_errors[:25], + "date_parse_error_count": len(date_parse_errors), "mirror": self.tally.mirror.status(client_id).get("mirror") or {}, } if query == "voucher":