Fix sundry creditors FIFO ageing from mirror voucher dates

This commit is contained in:
A R R R Associates
2026-09-09 16:55:15 +05:30
parent 078116f14f
commit a1828d645b
4 changed files with 227 additions and 87 deletions
@@ -268,24 +268,26 @@ def sundry_creditors_aging(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Age closing Sundry Creditors using FIFO from the Accounting Mirror. """Age closing Sundry Creditors using FIFO from the Accounting Mirror.
FIFO means debit-side settlements consume the oldest creditor balance first. The selected-FY closing is reconstructed from the mirror's ledger opening
At a reporting date, the unpaid closing balance is therefore represented by balance plus all creditor movements available up to the selected FY end.
the *latest* credit-side additions, with any residual carried from opening. 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 Voucher dates are normalized before ageing. This is intentionally strict:
balance and selected-period movements, then reconstruct its unpaid composition a dated movement that cannot be parsed is not silently treated as an old
backwards. This avoids using ledger_master.closing_balance for a historical FY opening balance, because doing so would incorrectly push current balances
because that master value can reflect a different/current reporting period. into the >180-day bucket.
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.
""" """
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( current = _creditor_movements(
node_code=node_code, node_code=node_code,
accounting_payload=accounting_payload, accounting_payload=accounting_payload,
from_date=fy_start, from_date="",
to_date=fy_end, to_date=fy_end,
limit=limit, limit=limit,
) )
@@ -304,15 +306,31 @@ def sundry_creditors_aging(
"Sundry Creditor movement extraction reached the safety limit. " "Sundry Creditor movement extraction reached the safety limit. "
"Increase the mirror query limit before relying on this analysis." "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"): if later.get("truncated"):
raise AccountingMirrorError( raise AccountingMirrorError(
"Follow-up Sundry Creditor movement extraction reached the safety limit. " "Follow-up Sundry Creditor movement extraction reached the safety limit. "
"Increase the mirror query limit before relying on the subsequent-payment analysis." "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_start_date = _date.fromisoformat(fy_start)
fy_end_date = _date.fromisoformat(fy_end) 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: def _money(value: Any) -> float:
try: try:
@@ -320,8 +338,35 @@ def sundry_creditors_aging(
except (TypeError, ValueError): except (TypeError, ValueError):
return 0.0 return 0.0
def _row_date(row: dict[str, Any]) -> str: def _parse_date(value: Any) -> _date | None:
return str(row.get("voucher_date") or "").strip() 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 = { ledger_meta = {
str(x.get("ledger_name") or "").casefold(): x str(x.get("ledger_name") or "").casefold(): x
@@ -330,17 +375,61 @@ def sundry_creditors_aging(
} }
by_ledger: dict[str, list[dict[str, Any]]] = {} 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() name = str(row.get("ledger_name") or "").strip()
if name: 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) 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]]] = {} 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() name = str(row.get("ledger_name") or "").strip()
if name: 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) 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]] = [] parties: list[dict[str, Any]] = []
details: list[dict[str, Any]] = [] details: list[dict[str, Any]] = []
@@ -356,45 +445,44 @@ def sundry_creditors_aging(
for party_name in party_names: for party_name in party_names:
key = party_name.casefold() key = party_name.casefold()
meta = ledger_meta.get(key) or {} 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, []) # Preserve the selected-FY closing-balance control already validated in v2:
# ledger opening + movements inside the selected FY. Earlier rows, when the
# IMPORTANT: ledger_master.closing_balance is a master-level snapshot and # mirror has them, are used only to identify the original date/composition of
# is not reliable as the closing balance for an arbitrary historical FY. # brought-forward FIFO lots; they are not added again to the closing balance.
# 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.
opening_balance = _money(meta.get("opening_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( 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" if str(row.get("dr_cr") or "").upper() == "CR"
), 2) ), 2)
period_debit = round(sum( 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" if str(row.get("dr_cr") or "").upper() == "DR"
), 2) ), 2)
historical_closing = round(opening_balance + period_credit - period_debit, 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) closing_balance = max(0.0, historical_closing)
if closing_balance <= 0.009: if closing_balance <= 0.009:
continue continue
credit_rows = [ credit_rows = [
row for row in 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: # FIFO reverse reconstruction. FIFO settlements clear oldest balances,
# oldest balances are settled first, therefore the newest credits remain. # so the newest credit lots are the ones that survive at the reporting date.
amount_to_allocate = closing_balance amount_to_allocate = closing_balance
lots_newest_first: list[dict[str, Any]] = [] lots_newest_first: list[dict[str, Any]] = []
for row in sorted( for row in reversed(credit_rows):
credit_rows,
key=lambda r: (_row_date(r), int(r.get("line_no") or 0)),
reverse=True,
):
if amount_to_allocate <= 0.009: if amount_to_allocate <= 0.009:
break break
source_amount = _money(row.get("amount")) source_amount = _money(row.get("amount"))
@@ -404,7 +492,7 @@ def sundry_creditors_aging(
lots_newest_first.append({ lots_newest_first.append({
"party_name": party_name, "party_name": party_name,
"source": "Voucher", "source": "Voucher",
"voucher_date": _row_date(row), "voucher_date": str(row.get("voucher_date") or ""),
"voucher_type": str(row.get("voucher_type") or ""), "voucher_type": str(row.get("voucher_type") or ""),
"voucher_number": str(row.get("voucher_number") or ""), "voucher_number": str(row.get("voucher_number") or ""),
"reference": str(row.get("reference") or ""), "reference": str(row.get("reference") or ""),
@@ -416,13 +504,13 @@ def sundry_creditors_aging(
}) })
amount_to_allocate = round(amount_to_allocate - allocated, 2) amount_to_allocate = round(amount_to_allocate - allocated, 2)
# Anything not represented by current-year credits is necessarily a # If the closing cannot be fully represented by dated credit rows available
# brought-forward closing component and is old at this FY close. # in this mirror, the residual is a genuine brought-forward component.
if amount_to_allocate > 0.009: if amount_to_allocate > 0.009:
lots_newest_first.append({ lots_newest_first.append({
"party_name": party_name, "party_name": party_name,
"source": "Opening / brought forward", "source": "Opening / brought forward",
"voucher_date": opening_date.isoformat(), "voucher_date": opening_fallback_date.isoformat(),
"voucher_type": "Opening", "voucher_type": "Opening",
"voucher_number": "", "voucher_number": "",
"reference": "", "reference": "",
@@ -432,14 +520,13 @@ def sundry_creditors_aging(
"paid_subsequently": 0.0, "paid_subsequently": 0.0,
"final_payment_date": "", "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( closing_lots = sorted(
lots_newest_first, 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: def settle_follow_up(amount: float, paid_on: str) -> None:
@@ -459,10 +546,10 @@ def sundry_creditors_aging(
for row in sorted( for row in sorted(
later_by_ledger.get(key, []), 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": 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 within_180 = 0.0
over_180 = 0.0 over_180 = 0.0
@@ -471,12 +558,8 @@ def sundry_creditors_aging(
over_180_lots: list[dict[str, Any]] = [] over_180_lots: list[dict[str, Any]] = []
for lot in closing_lots: for lot in closing_lots:
try: lot_date = _parse_date(lot.get("voucher_date")) or opening_fallback_date
lot_date = _date.fromisoformat(str(lot.get("voucher_date") or "")) age_days = max(0, (fy_end_date - lot_date).days)
except ValueError:
# A malformed/missing date must not be classified as current.
lot_date = opening_date
age_days = (fy_end_date - lot_date).days
bucket = ">180 Days" if age_days > 180 else "≤180 Days" bucket = ">180 Days" if age_days > 180 else "≤180 Days"
outstanding = _money(lot.get("outstanding_at_fy_end")) outstanding = _money(lot.get("outstanding_at_fy_end"))
paid = round(min(outstanding, _money(lot.get("paid_subsequently"))), 2) 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) within_180 = round(within_180 + outstanding, 2)
details.append({ details.append({
**{ "party_name": lot.get("party_name"),
k: lot.get(k) "source": lot.get("source"),
for k in ( "voucher_date": lot.get("voucher_date"),
"party_name", "source", "voucher_date", "voucher_type", "voucher_type": lot.get("voucher_type"),
"voucher_number", "reference", "original_credit", "final_payment_date", "voucher_number": lot.get("voucher_number"),
) "reference": lot.get("reference"),
}, "original_credit": lot.get("original_credit"),
"outstanding_at_fy_end": outstanding, "outstanding_at_fy_end": outstanding,
"age_days": age_days, "age_days": age_days,
"age_bucket": bucket, "age_bucket": bucket,
"paid_subsequently": paid, "paid_subsequently": paid,
"balance_after_follow_up": after, "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 = "" final_payment_date = ""
if over_180_lots and all(_money(lot.get("remaining")) <= 0.009 for lot in over_180_lots): 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: if dates:
final_payment_date = max(dates) final_payment_date = max(dates)
reconstructed = round(within_180 + over_180, 2) reconstructed = round(within_180 + over_180, 2)
# Maintain the selected-FY movement-derived closing balance as the control.
variance = round(closing_balance - reconstructed, 2) variance = round(closing_balance - reconstructed, 2)
if abs(variance) > 0.01: if abs(variance) > 0.01:
raise AccountingMirrorError( raise AccountingMirrorError(
@@ -545,9 +631,9 @@ def sundry_creditors_aging(
"parties": parties, "parties": parties,
"details": details, "details": details,
"allocation_basis": ( "allocation_basis": (
"FIFO from selected-FY Accounting Mirror movements; the historical " "FIFO from dated Accounting Mirror movements; historical closing is "
"closing balance is reconstructed from opening balance plus FY credits " "reconstructed from ledger opening plus selected-FY movements, "
"less FY debits, and newest credit-side additions form the unpaid closing balance" "and the newest surviving credit lots form the unpaid closing balance"
), ),
"current_mirror": current.get("mirror") or {}, "current_mirror": current.get("mirror") or {},
"follow_up_mirror": later.get("mirror") or {}, "follow_up_mirror": later.get("mirror") or {},
+1 -1
View File
@@ -4,7 +4,7 @@ import io
from pathlib import Path from pathlib import Path
import zipfile import zipfile
ERP_LOCAL_AGENT_VERSION = "1.26.11" ERP_LOCAL_AGENT_VERSION = "1.26.12"
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.11" __version__ = "1.26.12"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -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()] names = [str(x.get("ledger_name") or "").strip() for x in ledgers if str(x.get("ledger_name") or "").strip()]
rows = [] rows = []
date_parse_errors = []
truncated = False
if names: 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) placeholders = ",".join("?" for _ in names)
clauses = [f"LOWER(ledger_name) IN ({placeholders})"] # Do not filter dates in SQL here. Older .act files can contain
params = [x.casefold() for x in names] # Tally/ODBC date representations that are valid dates but are not
if from_date: # lexically comparable with YYYY-MM-DD. Normalize in Python first,
clauses.append("voucher_date>=?") # then apply the requested date window.
params.append(from_date) scan_limit = max(100000, min(250000, creditor_limit * 2))
if to_date:
clauses.append("voucher_date<=?")
params.append(to_date)
sql = ( sql = (
"SELECT voucher_guid,line_no,voucher_date,voucher_type,voucher_number,party_ledger," "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 " "ledger_name,dr_cr,amount,raw_amount FROM voucher_ledger_entry WHERE "
+ " AND ".join(clauses) f"LOWER(ledger_name) IN ({placeholders}) "
+ " ORDER BY ledger_name,voucher_date,line_no LIMIT ?" "ORDER BY ledger_name,line_no LIMIT ?"
) )
params.append(creditor_limit) raw_rows = [dict(r) for r in db.execute(
rows = [dict(r) for r in db.execute(sql, tuple(params)).fetchall()] 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 reference/narration are intentionally joined from the
# voucher header so the exported audit trail remains readable. # voucher header so the exported audit trail remains readable.
if rows: if rows:
@@ -4263,7 +4315,9 @@ class AgentCommandProcessor:
"ledgers": ledgers, "ledgers": ledgers,
"rows": rows, "rows": rows,
"count": len(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 {}, "mirror": self.tally.mirror.status(client_id).get("mirror") or {},
} }
if query == "voucher": if query == "voucher":