The reconciliation pass ruled EFECTIVO and EFECTIVO_BACKUP "near-disjoint ledgers" and the transform loaded both in full. That verdict was a bug, not a finding. reconcile.py compared the business key (cl, fecha, monto, conepto) as raw strings, on the stated premise that "every table went through the same mdb-export path, so identical source values serialize identically". They don't: mdb-export formats a numeric column from its Access column type, so the same amount is emitted as `5000` from one table and `27000.0000` from the other. No two rows could ever match on `monto`, which is why the pass reported 2 overlapping rows. Canonicalizing numeric key columns first shows 12386 of EFECTIVO_BACKUP's 12387 rows already exist verbatim in EFECTIVO — same customer, same timestamp to the second, same amount, same concept text — leaving exactly one genuinely new row. The ledger was carrying 12386 duplicated payments, roughly doubling every customer's historical receipt total. - reconcile.py: add canon(), which parses a key column to a number when nearly every populated cell parses and re-emits it at fixed precision. Applied in keyset() and in the folio-conflict comparison. Rewrite the group-1 verdict and the module docstring's method note. - transform_transactions.py: share a business-key `seen` set between the two efectivo_like() calls. EFECTIVO loads first and wins collisions. De-dup on the business key, never on folio — folio is per-table sequential and collides on 12204 different payments. - Regenerate RECONCILIATION.md. Groups 2 and 3 re-checked under the fix; their verdicts are unchanged. Ledger after re-running run_all.py --env dev: 45861 -> 33475 rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
10 KiB
Python
220 lines
10 KiB
Python
"""
|
||
Migration plan step 2: reconciliation pass.
|
||
|
||
The legacy Access files carry several sets of near-duplicate / overlapping
|
||
tables (snapshot copies, per-period exports, filtered views). Before the
|
||
transform+load (step 3) can union them, we need to know — from the data, not
|
||
from a guess — which rows are exact duplicates across those tables and which
|
||
are genuinely distinct, so the de-dup / union rule is decided by evidence.
|
||
|
||
Reads the staged Parquet produced by load_staging.py (`--output-dir`) and
|
||
prints a Markdown reconciliation report. Regenerate with:
|
||
|
||
./.venv/bin/python reconcile.py > RECONCILIATION.md
|
||
|
||
Method note: naive full-row matching across these tables gives ~0 overlap,
|
||
which is a trap — it hides *why*. The interesting question is whether two
|
||
tables hold the SAME economic records under cosmetic differences, or
|
||
genuinely DIFFERENT records. So each group is probed on a deliberate
|
||
*business key* (the columns that identify the real-world thing) and the
|
||
volatile/identity columns are examined separately.
|
||
|
||
Method correction (2026-07-22): this file previously assumed that "every table
|
||
went through the same mdb-export path, so identical source values serialize
|
||
identically". That is false. `mdb-export` formats a numeric column according to
|
||
its *Access column type*, so the same amount is emitted as `5000` from one table
|
||
and `27000.0000` from another. String-comparing a numeric key column therefore
|
||
reports near-zero overlap between tables holding identical records — which is
|
||
exactly what produced the original (wrong) "EFECTIVO / EFECTIVO_BACKUP are
|
||
near-disjoint ledgers" verdict. Every key column is now canonicalized (numeric
|
||
columns parsed and re-formatted to a fixed precision) before comparison.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pandas as pd
|
||
|
||
STG = Path(__file__).parent / "output" / "stg_utilities"
|
||
_META = ("_legacy_source_table", "_row_num")
|
||
_NULL = "∅"
|
||
|
||
|
||
def load(name: str) -> pd.DataFrame:
|
||
df = pd.read_parquet(STG / f"{name}.parquet")
|
||
df = df[[c for c in df.columns if c not in _META]].copy()
|
||
for c in df.columns:
|
||
df[c] = df[c].astype("string").str.strip().fillna(_NULL)
|
||
return df
|
||
|
||
|
||
def canon(sr: pd.Series) -> pd.Series:
|
||
"""Canonicalize one key column so it compares across tables.
|
||
|
||
A column that parses as a number in (nearly) every populated cell is
|
||
re-emitted at fixed precision, which erases the per-table formatting
|
||
mdb-export applies from the Access column type. Anything else (dates,
|
||
free text) is left as the already-stripped string.
|
||
"""
|
||
populated = sr.ne(_NULL)
|
||
if not populated.any():
|
||
return sr
|
||
num = pd.to_numeric(sr.where(populated), errors="coerce")
|
||
if num.notna().sum() < 0.9 * populated.sum():
|
||
return sr
|
||
return num.map(lambda v: _NULL if pd.isna(v) else f"{v:.4f}").astype("string")
|
||
|
||
|
||
def keyset(df: pd.DataFrame, cols: list[str]) -> set[str]:
|
||
keyed = pd.DataFrame({c: canon(df[c]) for c in cols})
|
||
return set(keyed.agg("\x1f".join, axis=1))
|
||
|
||
|
||
def overlap(a: pd.DataFrame, b: pd.DataFrame, cols: list[str]):
|
||
ka, kb = keyset(a, cols), keyset(b, cols)
|
||
return len(ka & kb), len(ka - kb), len(kb - ka)
|
||
|
||
|
||
def constant_cols(df: pd.DataFrame) -> list[str]:
|
||
return [c for c in df.columns if df[c].nunique(dropna=False) <= 1]
|
||
|
||
|
||
def p(*a):
|
||
print(*a)
|
||
|
||
|
||
def main() -> None:
|
||
p("# Reconciliation Report — Overlapping Legacy Tables")
|
||
p("")
|
||
p("_Migration plan step 2. Generated by `reconcile.py` from staged Parquet "
|
||
"(`output/stg_utilities`). Regenerate: `./.venv/bin/python reconcile.py "
|
||
"> RECONCILIATION.md`._")
|
||
p("")
|
||
p("**Headline:** `EFECTIVO_BACKUP` *is* a duplicate of `EFECTIVO` and must "
|
||
"not be double-loaded; the billing tables are genuinely disjoint period "
|
||
"runs; `COBRO3` is a mislabeled charge batch, not a customer master. See "
|
||
"each group's decided rule.")
|
||
p("")
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Group 1: EFECTIVO vs EFECTIVO_BACKUP
|
||
# ------------------------------------------------------------------ #
|
||
p("## 1. Cash ledger — `EFECTIVO` vs `EFECTIVO_BACKUP`")
|
||
p("")
|
||
ef, efb = load("efectivo"), load("efectivo_backup")
|
||
biz = ['cl', 'fecha', 'monto', 'conepto'] # what identifies a real payment
|
||
p(f"- `EFECTIVO`: {len(ef)} rows. `EFECTIVO_BACKUP`: {len(efb)} rows.")
|
||
# folio behaviour
|
||
fe = set(ef['folio']) - {_NULL}
|
||
fb = set(efb['folio']) - {_NULL}
|
||
p(f"- `folio` is per-table sequential: {ef['folio'].nunique()} distinct in "
|
||
f"EFECTIVO (= row count), {efb['folio'].nunique()} in BACKUP. "
|
||
f"{len(fe & fb)} folio *numbers* appear in both.")
|
||
both_folio = fe & fb
|
||
m = ef[ef['folio'].isin(both_folio)].drop_duplicates('folio').set_index('folio')
|
||
n = efb[efb['folio'].isin(both_folio)].drop_duplicates('folio').set_index('folio')
|
||
ci = m.index.intersection(n.index)
|
||
mk = pd.DataFrame({c: canon(m.loc[ci, c]) for c in biz})
|
||
nk = pd.DataFrame({c: canon(n.loc[ci, c]) for c in biz})
|
||
folio_conflict = int((mk != nk).any(axis=1).sum())
|
||
p(f"- **But of those {len(ci)} shared folio numbers, {folio_conflict} carry "
|
||
f"a *different* transaction** (differ on {biz}). → `folio` collides; it is "
|
||
"NOT a stable cross-table id, and it cannot be the de-dup key.")
|
||
b, oa, ob = overlap(ef, efb, biz)
|
||
p(f"- On the real business key `{biz}`, canonicalized: **{b} rows in both**, "
|
||
f"{oa} only in EFECTIVO, {ob} only in BACKUP — i.e. all but {ob} of "
|
||
f"BACKUP's {len(efb)} rows already exist verbatim in EFECTIVO, same "
|
||
"customer, same timestamp to the second, same amount, same concept text.")
|
||
p("- Yearly row counts track each other almost exactly from 2006 to 2023 "
|
||
"(e.g. 2017: 1036 vs 994), which is what a stale copy looks like — not "
|
||
"two ledgers covering different eras.")
|
||
p("")
|
||
p("**Decided rule (corrected):** `EFECTIVO_BACKUP` is a **stale backup copy "
|
||
"of `EFECTIVO`**, not an independent ledger. Load `EFECTIVO` in full, and "
|
||
"load from `EFECTIVO_BACKUP` only the rows whose canonicalized business "
|
||
f"key is absent from `EFECTIVO` ({ob} row(s)). Loading both in full "
|
||
f"double-counts {b} payments and doubles nearly every customer's "
|
||
"historical receipt total, which makes any statement or balance view "
|
||
"wrong. De-dup on the business key, **not** on `folio` (it collides).")
|
||
p("")
|
||
p("> This reverses the original verdict in this report, which put that "
|
||
"overlap at 2. That number came from string-comparing `monto`, which `mdb-export` "
|
||
"serializes with a different precision per table (`5000` vs "
|
||
"`27000.0000`) — see the module docstring.")
|
||
p("")
|
||
p("> `EFECTIVO FM3` (627) and `CHEQUE FM3` (157) are a separate stream — "
|
||
"`fee`/`tax`/`multa` columns instead of `monto` — and migrate as distinct "
|
||
"transactions, not reconciled against EFECTIVO.")
|
||
p("")
|
||
p("> Data-quality note for the transform: `monedas` has case/spelling "
|
||
"variants (`PESOS`/`Pesos`, `DOLARES`/`Dolares`/`DOLLARS`) — normalize "
|
||
"currency on load.")
|
||
p("")
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Group 2: datos2 vs FEE ANUAL vs fee15
|
||
# ------------------------------------------------------------------ #
|
||
p("## 2. Billing/fee logs — `datos2` vs `FEE ANUAL` vs `fee15`")
|
||
p("")
|
||
d2, fa, f15 = load("datos2"), load("fee_anual"), load("fee15")
|
||
p(f"- Rows: `datos2` {len(d2)}, `FEE ANUAL` {len(fa)}, `fee15` {len(f15)}.")
|
||
|
||
def date_span(df):
|
||
d = sorted(x for x in df['date'].unique() if x != _NULL)
|
||
return (d[0], d[-1]) if d else ("∅", "∅")
|
||
p(f"- `date` spans: `datos2` {date_span(d2)}, `FEE ANUAL` {date_span(fa)}, "
|
||
f"`fee15` {date_span(f15)} — **three different periods**.")
|
||
p(f"- `FEE ANUAL.refer` and `fee15.refer` are each a single constant value "
|
||
f"({fa['refer'].nunique()}/{f15['refer'].nunique()} distinct) — these are "
|
||
"one-shot per-period fee runs, not general logs.")
|
||
idcols = ['numid', 'date', 'chargecredit']
|
||
_, _, _ = overlap(fa, d2, idcols)
|
||
b1 = overlap(fa, d2, idcols)[0]
|
||
b2 = overlap(f15, d2, idcols)[0]
|
||
b3 = overlap(fa, f15, idcols)[0]
|
||
p(f"- Real-identity overlap `{idcols}`: FEE ANUAL∩datos2 = {b1}, "
|
||
f"fee15∩datos2 = {b2}, FEE ANUAL∩fee15 = {b3}.")
|
||
p("")
|
||
p("**Decided rule:** `datos2`, `FEE ANUAL`, and `fee15` are **disjoint "
|
||
"historical billing runs from different periods** (≈2025–26, 2018, 2017 "
|
||
"respectively), not copies of one another. Migrate **all three** into the "
|
||
"billing side of `transactions`, provenance-tagged; no de-dup is needed "
|
||
"(zero real-identity overlap). Preserve `datos2.due_date` (the other two "
|
||
"lack it — leave null for their rows).")
|
||
p("")
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Group 3: DATGRAL vs COBRO3
|
||
# ------------------------------------------------------------------ #
|
||
p("## 3. Customer master — `DATGRAL` vs `COBRO3`")
|
||
p("")
|
||
dg, cb = load("datgral"), load("cobro3")
|
||
dg_ids, cb_ids = set(dg['num_id']), set(cb['num_id'])
|
||
p(f"- `DATGRAL` {len(dg)} rows / `COBRO3` {len(cb)} rows; both unique on "
|
||
f"`num_id`.")
|
||
p(f"- Every `COBRO3.num_id` is in `DATGRAL` ({len(cb_ids & dg_ids)}/"
|
||
f"{len(cb_ids)}); it adds **no new customer id**.")
|
||
fee_const = cb['fee'].nunique()
|
||
fee_vals = list(cb['fee'].unique())[:3]
|
||
p(f"- **`COBRO3.fee` is constant** ({fee_const} distinct value: {fee_vals}) "
|
||
"while `DATGRAL.fee` varies per customer — so COBRO3 is not a snapshot of "
|
||
"the master's fee field.")
|
||
p("- Sampling shows COBRO3 rows carry the flat charge with names/addresses "
|
||
"denormalized (some blank), i.e. a saved *charge worklist*, not the "
|
||
"authoritative customer record.")
|
||
p("")
|
||
p("**Decided rule:** `COBRO3` is a **billing/charge batch** ('cobro' = "
|
||
"collection), not a customer table. `DATGRAL` is the sole utilities "
|
||
"customer master. **Do not** merge COBRO3 into `customers` or let it "
|
||
"overwrite any master field. If the flat charge has value as history, "
|
||
"model those 181 rows as charge `transactions` (amount = the constant "
|
||
"fee) keyed to the existing DATGRAL customers — otherwise exclude COBRO3 "
|
||
"from the migration entirely. Either way it contributes zero new "
|
||
"customers.")
|
||
p("")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|