Fix sundry creditors FIFO ageing from mirror voucher dates
This commit is contained in:
@@ -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,17 +375,61 @@ 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:
|
||||
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:
|
||||
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 {},
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user