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]:
"""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 {},
+1 -1
View File
@@ -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)
@@ -1,2 +1,2 @@
__version__ = "1.26.11"
__version__ = "1.26.12"
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()]
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":