A full run_all.py pass truncates and rebuilds every table it owns from the Access extract. That was harmless while the platform was a read-only mirror -- every row came from the extract, so wiping and rebuilding lost nothing. It stopped being harmless once the platform started minting rows Access has never heard of: allocated portal NUMids, customers created in the staff UI, OCR-captured policies, app-booked ledger rows, uploaded documents. REIMPORT is a button in /operaciones, so that was one click away. native_guard.py counts what only exists here and exits 3; run_all.py runs it before the first truncate and stops. Detecting an allocated NUMid needs the staged Parquet -- the customer holds an ordinary-looking (utilities, DATGRAL, '1172') ref, so "customer has no refs" cannot see it and only comparing against the extract can. Missing staging is therefore treated as blocking rather than as "nothing to protect". The guard does not teach full mode to preserve anything: --sync already upserts legacy rows against the existing refs and leaves the rest alone, and rebuilding that inside full mode would re-implement it. --force-full (checkbox in the REIMPORT confirm, recorded in the audit log) deletes them deliberately. Verified against dev: clean before, exit 3 listing utilities/1172 with a synthetic ref present, clean again after removing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
8.2 KiB
Python
211 lines
8.2 KiB
Python
"""
|
|
Refuse a full re-import that would delete platform-native data.
|
|
|
|
A full `run_all.py` pass truncates and rebuilds every table it owns from the
|
|
Access extract:
|
|
|
|
transform_customers.py customers, customer_legacy_refs
|
|
transform_properties.py properties, property_services, service_documents,
|
|
trust_accounts
|
|
transform_policies.py policies + installments, vehicles, drivers,
|
|
beneficiaries, claims, adjusters, policy_types,
|
|
insurance_providers
|
|
transform_transactions.py transactions, type_transactions, exchange_rates
|
|
transform_bank.py bank tables
|
|
blob_extract.py service_documents, policy_documents
|
|
|
|
That was harmless while the platform was a read-only mirror of Access: every
|
|
row came from the extract, so wiping and rebuilding lost nothing. It stopped
|
|
being harmless when the platform started minting rows Access has never heard
|
|
of — portal NUMids from the allocator (apps/api/src/customers/numid.service.ts),
|
|
customers created in the staff UI, OCR-captured policies, app-booked ledger
|
|
rows, uploaded documents. None of those come back.
|
|
|
|
`--sync` already avoids all of it: it upserts legacy rows against the existing
|
|
refs and leaves everything else alone. So this guard does not try to teach the
|
|
full path to preserve anything — it stops the full path when there is something
|
|
to preserve, and points at the additive one.
|
|
|
|
python native_guard.py --env prod # report only, exit 3 if blocking
|
|
python run_all.py --env prod --stage # runs this first, refuses on 3
|
|
python run_all.py --env prod --force-full # ignore the guard (deletes them)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
|
|
from dbenv import connect
|
|
|
|
STG = Path(__file__).parent / "output"
|
|
|
|
# Exit code the orchestrator looks for. Distinct from 1 so a connection failure
|
|
# or a bad query is not silently read as "native rows found".
|
|
BLOCKED = 3
|
|
|
|
# transform_customers.py mints these when a legacy row carries no id of its own.
|
|
# They are regenerated by every full pass, so they are legacy-owned, not native.
|
|
SYNTHETIC_REF_PREFIXES = ("rownum_", "insrow_")
|
|
|
|
# App-uploaded documents are stored as `<prefix>/<parent>/<uuid>.<ext>`, while
|
|
# blob_extract writes `<prefix>/<parent>/<stagedtable>_<row>_<col>.<ext>`.
|
|
# service_documents carries no provenance column, so the key shape is the only
|
|
# signal available — approximate, and reported as such. Matched in MySQL rather
|
|
# than in Python so the whole scan stays one round trip per table.
|
|
UUID_KEY_SQL = "/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\."
|
|
|
|
|
|
def staged_legacy_ids() -> dict[str, set[str]] | None:
|
|
"""The ids Access will re-create, per source system.
|
|
|
|
None when the staged Parquet is absent — which is not the same as "no legacy
|
|
ids". Returning an empty set there would mark all 1,171 refs as native and
|
|
block every run; returning None lets the caller say "cannot verify" instead.
|
|
"""
|
|
sources = {"utilities": "stg_utilities", "insurance": "stg_seguros"}
|
|
out: dict[str, set[str]] = {}
|
|
for system, folder in sources.items():
|
|
path = STG / folder / "datgral.parquet"
|
|
if not path.exists():
|
|
return None
|
|
df = pd.read_parquet(path, columns=["num_id"])
|
|
ids = set()
|
|
for v in df["num_id"].astype("string"):
|
|
if v is None or pd.isna(v):
|
|
continue
|
|
v = str(v).strip()
|
|
if v.endswith(".0"): # some numeric ids serialize as "521.0"
|
|
v = v[:-2]
|
|
if v and v != "0":
|
|
ids.add(v)
|
|
out[system] = ids
|
|
return out
|
|
|
|
|
|
def native_refs(cur, staged: dict[str, set[str]] | None) -> tuple[int, list[str]]:
|
|
"""Legacy refs with no counterpart in the Access extract.
|
|
|
|
This is the check that catches an allocated portal NUMid: the customer holds
|
|
a perfectly ordinary-looking (utilities, DATGRAL, '1172') ref, so "customer
|
|
has no refs" does not see it. Only comparing against staging does.
|
|
"""
|
|
cur.execute(
|
|
"SELECT sourceSystem, legacyId FROM customer_legacy_refs ORDER BY sourceSystem, legacyId"
|
|
)
|
|
rows = cur.fetchall()
|
|
if staged is None:
|
|
return 0, []
|
|
|
|
found = []
|
|
for system, legacy_id in rows:
|
|
if legacy_id.startswith(SYNTHETIC_REF_PREFIXES):
|
|
continue
|
|
known = staged.get(system)
|
|
# An unknown source system has no extract to compare against, so it
|
|
# cannot be re-created either — treat it as native rather than ignoring.
|
|
if known is None or legacy_id not in known:
|
|
found.append(f"{system}/{legacy_id}")
|
|
return len(found), found
|
|
|
|
|
|
def scan(conn) -> tuple[list[tuple[str, int, str]], bool]:
|
|
"""(label, count, detail) per source of native rows, plus whether staging
|
|
was available to verify the refs."""
|
|
cur = conn.cursor()
|
|
staged = staged_legacy_ids()
|
|
findings: list[tuple[str, int, str]] = []
|
|
|
|
ref_count, ref_examples = native_refs(cur, staged)
|
|
if ref_count:
|
|
shown = ", ".join(ref_examples[:8])
|
|
more = f" (+{ref_count - 8} more)" if ref_count > 8 else ""
|
|
findings.append(("customer_legacy_refs", ref_count, f"{shown}{more}"))
|
|
|
|
cur.execute(
|
|
"SELECT COUNT(*) FROM customers c"
|
|
" WHERE NOT EXISTS (SELECT 1 FROM customer_legacy_refs r WHERE r.customerId = c.id)"
|
|
)
|
|
n = cur.fetchone()[0]
|
|
if n:
|
|
findings.append(("customers", n, "created in the staff UI, no legacy ref"))
|
|
|
|
# Every transform writes legacyId on what it loads, so a NULL is the app's.
|
|
for table, detail in (
|
|
("transactions", "booked in the app (captura, OCR, manual)"),
|
|
("policies", "created in the app or captured by policy OCR"),
|
|
("properties", "created in the app"),
|
|
("vehicles", "created in the app"),
|
|
("bank_transactions", "booked in the chequera"),
|
|
):
|
|
cur.execute(f"SELECT COUNT(*) FROM {table} WHERE legacyId IS NULL")
|
|
n = cur.fetchone()[0]
|
|
if n:
|
|
findings.append((table, n, detail))
|
|
|
|
# blob_extract always writes originalColumn; the app never does.
|
|
cur.execute("SELECT COUNT(*) FROM policy_documents WHERE originalColumn IS NULL")
|
|
n = cur.fetchone()[0]
|
|
if n:
|
|
findings.append(("policy_documents", n, "uploaded in the app"))
|
|
|
|
cur.execute("SELECT COUNT(*) FROM service_documents WHERE storageKey REGEXP %s",
|
|
(UUID_KEY_SQL,))
|
|
n = cur.fetchone()[0]
|
|
if n:
|
|
findings.append(("service_documents", n, "uploaded in the app (key shape, approximate)"))
|
|
|
|
return findings, staged is not None
|
|
|
|
|
|
def report(findings, staged_ok: bool, env: str) -> int:
|
|
print(f"=== Verificación de datos nativos (env={env}) ===", flush=True)
|
|
|
|
if not staged_ok:
|
|
print(
|
|
" ! No hay Parquet en migration/output, así que no se pueden verificar\n"
|
|
" los refs contra el extracto de Access. Ejecute con --stage.",
|
|
flush=True,
|
|
)
|
|
|
|
if not findings:
|
|
print(" Sin filas nativas. Una reimportación completa no destruye nada.", flush=True)
|
|
return 0 if staged_ok else BLOCKED
|
|
|
|
total = sum(n for _, n, _ in findings)
|
|
print(f" {total} filas existen SÓLO en la plataforma y se perderían:", flush=True)
|
|
for label, n, detail in findings:
|
|
print(f" {n:>7} {label:<22} {detail}", flush=True)
|
|
print(
|
|
"\n Una reimportación completa vacía estas tablas y las reconstruye desde\n"
|
|
" Access, que no conoce ninguna de estas filas.\n"
|
|
"\n Use la sincronización aditiva (run_all.py --sync), que respeta lo\n"
|
|
" capturado en la plataforma. Para reimportar de todos modos y BORRARLAS,\n"
|
|
" ejecute run_all.py --force-full.",
|
|
flush=True,
|
|
)
|
|
return BLOCKED
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
ap.add_argument("--env", default="dev")
|
|
args = ap.parse_args()
|
|
|
|
conn = connect(args.env)
|
|
try:
|
|
findings, staged_ok = scan(conn)
|
|
finally:
|
|
conn.close()
|
|
|
|
raise SystemExit(report(findings, staged_ok, args.env))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|