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]:
|
) -> 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 {},
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user