The --sync path had never been run and was broken in several ways. Fixed and verified against the dev DB (two consecutive syncs, both exit 0, 32/32 assertions: stable PKs, manual-row preservation, changed-row updates, legacy-delete, no child duplication, zero FK orphans; idempotent). - policies/properties: reuse each legacy row's existing id (by provenance) BEFORE building child rows, so children no longer point at a discarded fresh uuid; rebuild legacy-owned children via scoped delete + reinsert. - customers: replace zip(customers, refs) (mispaired almost every row) with a ref-grouped id remap; names now restore and no spurious customers appear. - drop the invalid Vehicle @@unique(legacySourceTable, legacyId) — one legacy policy row carries up to 3 vehicles sharing a legacyId; handle via delete+reinsert. - upsert lookup tables (policy_types, insurance_providers, type_transactions, adjusters) by natural name and remap child FKs instead of inserting fresh uuids that nothing points at. - transactions: drop updatedAt=NOW() (no such column); guard report formatting on NULL legacySourceTable (manual rows). Same report guard in bank. - add manual-safe prune (prune_empty_customers.py --sync, in SYNC_STEPS): prune only legacy-owned empties, never manually-added customers. web: customer-detail mini tx list now strikes voided rows with an "(anulado)" tag (was the last void-UI rendering gap; /estado-cuenta already handled it). docs: RESUME.md updated — Phase B sync marked verified end-to-end, void-UI browser pass recorded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
5.7 KiB
Python
137 lines
5.7 KiB
Python
"""
|
|
Migration step 5: drop customers that carry no business records at all.
|
|
|
|
A customer is "empty" when it owns zero properties, zero policies and zero
|
|
transactions — the legacy DATGRAL row exists, but nothing in either business
|
|
line ever attached to it. These are dead ID slots and never-activated
|
|
prospects from the Access era, and they pad the staff customer list with
|
|
rows that can't be acted on.
|
|
|
|
This runs LAST in the customer graph, not inside transform_customers.py,
|
|
because emptiness is only knowable after properties, policies and
|
|
transactions have loaded. Deciding it here also means the rule stays in one
|
|
place instead of being re-derived from the staged Parquet by duplicating each
|
|
downstream transform's source-matching logic.
|
|
|
|
Safe by construction: a customer with zero rows in all three tables has
|
|
nothing pointing at it, so the delete cannot orphan anything. Only its own
|
|
`customer_legacy_refs` rows go with it.
|
|
|
|
Nothing is silently destroyed — every deleted customer is written to
|
|
`output/pruned_customers.csv` (with its legacy provenance) before the delete,
|
|
and the whole step is reproducible from the Access sources by re-running the
|
|
pipeline without it.
|
|
|
|
Run: ./.venv/bin/python prune_empty_customers.py --env dev [--dry-run]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
from pathlib import Path
|
|
|
|
from dbenv import connect
|
|
|
|
AUDIT = Path(__file__).parent / "output" / "pruned_customers.csv"
|
|
|
|
# The emptiness test. Kept as one string so the audit dump and the delete can
|
|
# never disagree about what "empty" means.
|
|
EMPTY_WHERE = """
|
|
NOT EXISTS (SELECT 1 FROM properties x WHERE x.customerId = c.id)
|
|
AND NOT EXISTS (SELECT 1 FROM policies x WHERE x.customerId = c.id)
|
|
AND NOT EXISTS (SELECT 1 FROM transactions x WHERE x.customerId = c.id)
|
|
"""
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--env", default="dev", help="target environment (reads deploy/.env.<env>)")
|
|
ap.add_argument("--dry-run", action="store_true",
|
|
help="report and write the audit CSV, but delete nothing")
|
|
ap.add_argument("--sync", action="store_true",
|
|
help="only prune legacy-owned empties; never touch manually-added "
|
|
"customers (those with no customer_legacy_refs row)")
|
|
args = ap.parse_args()
|
|
|
|
conn = connect(args.env)
|
|
cur = conn.cursor()
|
|
print(f"[prune] target env: {args.env}{' (sync: legacy-owned only)' if args.sync else ''}")
|
|
|
|
# In sync mode a manually-added customer (no legacy ref) with no records yet
|
|
# is a legitimate new row, not Access-era dead weight — so restrict the prune
|
|
# to customers that carry a legacy ref.
|
|
where = EMPTY_WHERE + (
|
|
"\nAND EXISTS (SELECT 1 FROM customer_legacy_refs lr WHERE lr.customerId = c.id)"
|
|
if args.sync else "")
|
|
|
|
cur.execute("SELECT COUNT(*) FROM customers")
|
|
before = cur.fetchone()[0]
|
|
|
|
# Snapshot what is about to go, provenance included, for the audit trail.
|
|
cur.execute(f"""
|
|
SELECT c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state,
|
|
c.email, c.phone, c.mobile, c.status,
|
|
GROUP_CONCAT(CONCAT(r.sourceSystem, ':', r.sourceTable, ':', r.legacyId)
|
|
ORDER BY r.sourceSystem SEPARATOR ' | ')
|
|
FROM customers c
|
|
LEFT JOIN customer_legacy_refs r ON r.customerId = c.id
|
|
WHERE {where}
|
|
GROUP BY c.id
|
|
ORDER BY c.nameMissing, c.name
|
|
""")
|
|
rows = cur.fetchall()
|
|
|
|
AUDIT.parent.mkdir(parents=True, exist_ok=True)
|
|
with AUDIT.open("w", newline="", encoding="utf-8") as fh:
|
|
w = csv.writer(fh)
|
|
w.writerow(["id", "name", "nameSource", "nameMissing", "city", "state",
|
|
"email", "phone", "mobile", "status", "legacyRefs"])
|
|
w.writerows(rows)
|
|
|
|
named = sum(1 for r in rows if not r[3])
|
|
nameless = len(rows) - named
|
|
|
|
if args.dry_run:
|
|
print(f" dry run — {len(rows)} would be pruned, nothing deleted")
|
|
else:
|
|
# Delete by the exact id set selected above (which already carries the
|
|
# sync guard). Deleting via the id list avoids referencing the delete
|
|
# target table inside its own WHERE (MySQL error 1093) and keeps refs +
|
|
# customers on the same set regardless of delete order.
|
|
ids = [r[0] for r in rows]
|
|
refs_deleted = deleted = 0
|
|
if ids:
|
|
fmt = ",".join(["%s"] * len(ids))
|
|
cur.execute(f"DELETE FROM customer_legacy_refs WHERE customerId IN ({fmt})", ids)
|
|
refs_deleted = cur.rowcount
|
|
cur.execute(f"DELETE FROM customers WHERE id IN ({fmt})", ids)
|
|
deleted = cur.rowcount
|
|
conn.commit()
|
|
print(f" deleted {deleted} customers, {refs_deleted} legacy refs")
|
|
|
|
cur.execute("SELECT COUNT(*) FROM customers")
|
|
after = cur.fetchone()[0]
|
|
|
|
print("=== Empty-customer prune complete ===")
|
|
print(f" customers before : {before}")
|
|
print(f" empty (no property, policy or transaction) : {len(rows)}")
|
|
print(f" named : {named}")
|
|
print(f" without a name : {nameless}")
|
|
print(f" customers after : {after}")
|
|
print(f" audit trail : {AUDIT}")
|
|
|
|
# Whatever survived must still have every downstream FK intact.
|
|
for table in ("properties", "policies", "transactions"):
|
|
cur.execute(f"SELECT COUNT(*) FROM {table} t "
|
|
f"LEFT JOIN customers c ON c.id = t.customerId WHERE c.id IS NULL")
|
|
orphans = cur.fetchone()[0]
|
|
assert orphans == 0, f"{table}: {orphans} orphaned rows after prune"
|
|
print(" validation: OK (0 orphans)")
|
|
conn.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|