Files
jorgecuadros-platform/migration/reconcile.py
T
rmancinasandClaude Opus 4.8 9de8e4e6c0 fix(migration): de-duplicate EFECTIVO_BACKUP against EFECTIVO
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>
2026-07-22 23:28:18 -07:00

220 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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** (≈202526, 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()