277 lines
14 KiB
Python
277 lines
14 KiB
Python
"""
|
|
Month-end controls.
|
|
|
|
Why this module exists
|
|
----------------------
|
|
The close previously reported "Reconciled" from a single identity:
|
|
|
|
uploaded_total == receivable_orders + paid_orders + transfers_total
|
|
|
|
That identity cannot fail. `uploaded_total` is accumulated from the same parsed record
|
|
stream that fills the three buckets, and every record lands in exactly one of them, so the
|
|
two sides are the same sum written twice. It reported "Reconciled" on the Jan-2026 close
|
|
while the Reconciliation Control was understating the group receivable by USD 444,658.44,
|
|
and it would report "Reconciled" just as happily if an entire marketplace file failed to
|
|
parse — because a file that yields no rows contributes zero to *both* sides.
|
|
|
|
Every control below is instead **independent of the thing it checks**: it compares the
|
|
engine's output against something the engine did not produce (the worksheet's own declared
|
|
extent, the source `total` column, the FX rates a human confirmed, a second calculation
|
|
method). A control that cannot fail is not a control.
|
|
|
|
Severity `error` blocks the close: no receivable figure is released to the dashboard or to
|
|
an export until it is resolved. A number that cannot be trusted is never shown — a missing
|
|
number cannot be posted to the ledger, a wrong one can.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
# Roll-forward vs settlement method may legitimately differ by rounding across marketplaces.
|
|
DUAL_METHOD_TOLERANCE = 1.0
|
|
# Σ(journal lines) vs Σ(total): float accumulation over millions of rows, not a real gap.
|
|
COMPONENT_TOLERANCE = 0.05
|
|
|
|
PASS, FAIL, NA = "pass", "fail", "not_applicable"
|
|
|
|
|
|
@dataclass
|
|
class ControlResult:
|
|
key: str
|
|
label: str
|
|
status: str = PASS
|
|
severity: str = "error"
|
|
detail: str = ""
|
|
evidence: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def blocking(self) -> bool:
|
|
return self.status == FAIL and self.severity == "error"
|
|
|
|
|
|
def _ok(key: str, label: str, detail: str) -> ControlResult:
|
|
return ControlResult(key=key, label=label, status=PASS, detail=detail)
|
|
|
|
|
|
def _na(key: str, label: str, detail: str) -> ControlResult:
|
|
return ControlResult(key=key, label=label, status=NA, severity="info", detail=detail)
|
|
|
|
|
|
def _fail(key: str, label: str, detail: str, evidence: list[str],
|
|
severity: str = "error") -> ControlResult:
|
|
return ControlResult(key=key, label=label, status=FAIL, severity=severity,
|
|
detail=detail, evidence=evidence[:10])
|
|
|
|
|
|
# --------------------------------------------------------------------------- C1
|
|
def c1_source_row_count(file_metas) -> ControlResult:
|
|
"""
|
|
Every data row the worksheet declares must be either imported or deliberately skipped.
|
|
|
|
Independent because the expected count comes from the worksheet's own <dimension>, read
|
|
from the file header — not from our row stream. This is the control that distinguishes
|
|
"this marketplace had no transactions" from "this marketplace's file failed to parse",
|
|
which otherwise produce an identical, silent zero.
|
|
"""
|
|
key, label = "C1", "Source row count"
|
|
checked, unknown, bad = 0, [], []
|
|
for m in file_metas:
|
|
if not getattr(m, "sheet_last_row", 0):
|
|
unknown.append(m.filename)
|
|
continue
|
|
checked += 1
|
|
expected = m.expected_data_rows
|
|
got = m.rows_accounted_for
|
|
if got != expected:
|
|
bad.append(
|
|
f"{m.filename}: worksheet declares {expected:,} data rows "
|
|
f"(rows 1..{m.sheet_last_row}, header row {m.header_row}) but "
|
|
f"{got:,} were accounted for "
|
|
f"(imported {m.imported_rows:,}, helper {m.helper_rows_skipped:,}, "
|
|
f"blank {m.blank_rows_skipped:,}) — {expected - got:+,} unexplained")
|
|
if bad:
|
|
return _fail(key, label,
|
|
f"{len(bad)} file(s) consumed a different number of rows than the "
|
|
f"worksheet declares. Rows may have been dropped during parsing.", bad)
|
|
if not checked:
|
|
return _na(key, label, "No worksheet declared its extent; row counts unverifiable.")
|
|
note = f"{checked} file(s) fully accounted for."
|
|
if unknown:
|
|
note += f" {len(unknown)} file(s) declared no extent: {', '.join(unknown[:3])}."
|
|
return _ok(key, label, note)
|
|
|
|
|
|
# --------------------------------------------------------------------------- C2
|
|
def c2_column_completeness(journal_payload: dict, uploaded_total: float) -> ControlResult:
|
|
"""
|
|
Every amount column must be accounted for: Σ(journal GL lines) == Σ(`total` column).
|
|
|
|
Independent because the journal is built by summing the individual component columns
|
|
(product sales, fees, tax, …) while `total` is Amazon's own pre-computed column AD. If a
|
|
column is unmapped, mapped twice, or a new column appears, the two sides separate. This
|
|
is what catches an Australia-style `fulfilment by amazon fees` alias gap, and unlike the
|
|
old identity it is a genuine cross-check between two different numbers.
|
|
"""
|
|
key, label = "C2", "Column completeness"
|
|
if not journal_payload or not journal_payload.get("lines"):
|
|
return _na(key, label, "No journal decomposition available.")
|
|
|
|
per_market = journal_payload.get("per_marketplace") or {
|
|
journal_payload.get("marketplace", "USA"): journal_payload}
|
|
bad, total_lines = [], 0.0
|
|
for mkt, jr in per_market.items():
|
|
s = sum(l.get("total", 0.0) for l in jr.get("lines", []))
|
|
total_lines += s
|
|
delta = round(uploaded_total - total_lines, 2)
|
|
if abs(delta) > COMPONENT_TOLERANCE:
|
|
for mkt, jr in per_market.items():
|
|
s = sum(l.get("total", 0.0) for l in jr.get("lines", []))
|
|
bad.append(f"{mkt}: GL lines sum to {s:,.2f}")
|
|
return _fail(key, label,
|
|
f"Sum of GL lines {total_lines:,.2f} != sum of the source `total` column "
|
|
f"{uploaded_total:,.2f} (difference {delta:,.2f}). An amount column is "
|
|
f"unmapped, mapped twice, or newly added by Amazon.", bad)
|
|
return _ok(key, label,
|
|
f"GL lines reconcile to the source `total` column "
|
|
f"({total_lines:,.2f}, difference {delta:,.2f}).")
|
|
|
|
|
|
# --------------------------------------------------------------------------- C3
|
|
def c3_bucket_completeness(agg) -> ControlResult:
|
|
"""
|
|
Every money-carrying order row must be placeable in a receivable bucket.
|
|
|
|
A row whose `account type` is unrecognized fails the receivable SUMIFS filter; a row
|
|
whose settlement id is not numeric sorts below every boundary and is classified "paid".
|
|
Both silently REDUCE the receivable, and neither previously raised anything.
|
|
"""
|
|
key, label = "C3", "Bucket completeness"
|
|
problems, evidence = [], []
|
|
if agg.unclassified_acct_count:
|
|
problems.append(
|
|
f"{agg.unclassified_acct_count:,} row(s) carrying "
|
|
f"{agg.unclassified_acct_total:,.2f} have an unrecognized account type")
|
|
evidence += agg.unclassified_acct_samples
|
|
if agg.unclassified_sid_count:
|
|
problems.append(
|
|
f"{agg.unclassified_sid_count:,} row(s) carrying "
|
|
f"{agg.unclassified_sid_total:,.2f} have a blank or non-numeric settlement id")
|
|
evidence += agg.unclassified_sid_samples
|
|
if problems:
|
|
return _fail(key, label,
|
|
"; ".join(problems) + ". These are excluded from the receivable — "
|
|
"map or correct them before relying on this close.", evidence)
|
|
return _ok(key, label, "Every order row resolves to a receivable bucket.")
|
|
|
|
|
|
# --------------------------------------------------------------------------- C4
|
|
def c4_dual_method(rows: list[dict], openings_all_zero: bool = False) -> ControlResult:
|
|
"""
|
|
The two independent closing methods must agree, per marketplace, in LOCAL currency:
|
|
|
|
settlement method : ROUND(reserve + additional sales)
|
|
roll-forward : opening AR balance + net revenue - payouts received
|
|
|
|
Independent because they share no arithmetic — one filters open settlements, the other
|
|
accumulates GL movement off an opening balance. A gap means the opening balance, a
|
|
reserve, or a timing item is wrong.
|
|
|
|
Reported as a warning rather than a block, because the opening balance is a Finance input
|
|
the engine cannot derive: until it is entered (or carried forward from the prior closing)
|
|
the roll-forward is measuring only this month's movement and MUST differ. That case is
|
|
called out explicitly — "no openings entered yet" is a different situation from "openings
|
|
are entered and the two methods still disagree", and only the second is a real problem.
|
|
"""
|
|
key, label = "C4", "Dual-method agreement"
|
|
if not rows:
|
|
return _na(key, label, "No per-marketplace movement available.")
|
|
bad = []
|
|
for r in rows:
|
|
s, c = r.get("settlement_closing"), r.get("closing_local")
|
|
if s is None or c is None:
|
|
continue
|
|
d = round(c - s, 2)
|
|
if abs(d) > DUAL_METHOD_TOLERANCE:
|
|
bad.append(f"{r['marketplace']}: roll-forward {c:,.2f} vs settlement {s:,.2f} "
|
|
f"({r.get('currency', '')}) — difference {d:,.2f}")
|
|
if bad:
|
|
if openings_all_zero:
|
|
detail = (f"Every opening AR balance on this closing is zero, so the roll-forward "
|
|
f"({len(bad)} marketplace(s) differing) is only measuring this month's "
|
|
f"movement — not the receivable actually outstanding. The settlement "
|
|
f"figure is the reliable one until opening balances are entered. "
|
|
f"Carry them forward from the prior closing, or enter them from last "
|
|
f"month's workbook, on the Opening Balances tab.")
|
|
else:
|
|
detail = (f"{len(bad)} marketplace(s) disagree between the two closing methods "
|
|
f"even with opening balances entered. Review the opening balance, the "
|
|
f"reserve, or a month-boundary timing item for each one below.")
|
|
return _fail(key, label, detail, bad, severity="warning")
|
|
return _ok(key, label, f"Both methods agree across {len(rows)} marketplace(s).")
|
|
|
|
|
|
# --------------------------------------------------------------------------- C5
|
|
def c5_fx_confirmed(fx_rows, reporting_month: str, markets: list[str]) -> ControlResult:
|
|
"""
|
|
Every non-USD marketplace must have an FX rate a human confirmed FOR THIS MONTH.
|
|
|
|
The seeded `DEFAULT_FX_USD` table is a January-2026 snapshot. Left as a default it would
|
|
value a July close at January's rates and say nothing, so a seeded-but-unconfirmed rate
|
|
counts as missing.
|
|
"""
|
|
key, label = "C5", "FX rates confirmed"
|
|
by_mkt = {r.marketplace: r for r in fx_rows}
|
|
missing = []
|
|
for mkt in markets:
|
|
row = by_mkt.get(mkt)
|
|
if row is None:
|
|
missing.append(f"{mkt}: no FX rate recorded")
|
|
continue
|
|
if (row.currency or "USD").upper() == "USD" and float(row.rate or 1.0) == 1.0:
|
|
continue # USD at parity needs no confirmation
|
|
if not row.confirmed_by:
|
|
missing.append(f"{mkt}: rate {row.rate} ({row.currency}) is a seeded default "
|
|
f"[{row.source or 'unknown source'}] — not confirmed by anyone")
|
|
elif (row.confirmed_month or "") != reporting_month:
|
|
missing.append(f"{mkt}: rate {row.rate} ({row.currency}) was confirmed for "
|
|
f"{row.confirmed_month or 'an unknown month'}, not {reporting_month}")
|
|
if missing:
|
|
return _fail(key, label,
|
|
f"{len(missing)} marketplace(s) have no FX rate confirmed for "
|
|
f"{reporting_month}. Converted totals would use stale rates.", missing)
|
|
return _ok(key, label, f"FX confirmed for {reporting_month} across all marketplaces.")
|
|
|
|
|
|
# --------------------------------------------------------------------------- C6
|
|
def c6_currency_integrity(control_total_usd: float | None,
|
|
all_markets_total_usd: float | None) -> ControlResult:
|
|
"""
|
|
Two surfaces roll the marketplaces up to a group total; they must agree to the cent.
|
|
|
|
Independent because they are separate code paths over the same data. Before the fix the
|
|
Reconciliation Control added the local-currency closings together (USD + EUR + GBP + PLN
|
|
+ SEK + CAD + AUD as one figure) while the All-Markets tab converted properly — a
|
|
USD 444,658.44 gap on Jan-2026, on the figure that gates sign-off.
|
|
"""
|
|
key, label = "C6", "Currency integrity"
|
|
if control_total_usd is None or all_markets_total_usd is None:
|
|
return _na(key, label, "Group totals not available.")
|
|
d = round(control_total_usd - all_markets_total_usd, 2)
|
|
if abs(d) > 0.01:
|
|
return _fail(key, label,
|
|
f"Group closing receivable differs between surfaces: Reconciliation "
|
|
f"Control {control_total_usd:,.2f} vs All Markets "
|
|
f"{all_markets_total_usd:,.2f} (difference {d:,.2f} USD). One of them is "
|
|
f"adding currencies without converting.",
|
|
[f"difference {d:,.2f} USD"])
|
|
return _ok(key, label,
|
|
f"Both group roll-ups agree at {control_total_usd:,.2f} USD.")
|
|
|
|
|
|
def blocking_summary(results: list[ControlResult]) -> str:
|
|
"""One-line reason a close is blocked, or "" when nothing blocks."""
|
|
blockers = [r for r in results if r.blocking]
|
|
if not blockers:
|
|
return ""
|
|
return " | ".join(f"{r.key} {r.label}: {r.detail}" for r in blockers)
|