Files
rmancinasandClaude Opus 5 17d83291c3
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m51s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m42s
feat(migration): refuse a full re-import that would delete native rows
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>
2026-08-06 21:01:01 -07:00

105 lines
5.8 KiB
SQL

-- One row per portal NUMid, with every signal that says whether the id is in use.
-- Consumed by scripts/numid-audit.mjs, which applies the tier rules.
--
-- POOL. customer_legacy_refs where sourceSystem='utilities' AND sourceTable='DATGRAL'.
-- That pair IS the portal "Security Number" the login screen asks for.
-- insurance/DATGRAL is a DIFFERENT id space running to 4000 and sharing the same
-- sourceTable name; drawing from it would hand out an id the portal cannot resolve.
--
-- WHY THE OBVIOUS RULES FIND NOTHING.
-- "every owned row count is zero" -> 0 of 1,171. Migration gave every NUMid
-- at least one property and one transaction.
-- "no transaction in the last N years" -> 0 of 1,171. Every customer carries a
-- synthetic Jan-1 opening-balance row, so
-- everyone looks active in the current year.
-- The opening-balance row has to be subtracted before any of this means anything,
-- which is what `bf` below does and why `real_tx` exists.
--
-- BALANCE-FORWARD DETECTION IS TWO-SHAPED ON PURPOSE.
-- transform_transactions.py:120 mints a transaction type literally named
-- 'BALANCE FORWARD'. Databases loaded before that change carry the same rows with
-- typeId NULL, dated Jan 1, legacySourceTable='datos2' -- 1,170 of them, exactly one
-- per customer. Matching the type name alone floors nothing on such a database, and
-- every balance below silently becomes a raw lifetime sum: the same double-count that
-- read the whole book as +20.6M MXN in credit before d173c9e. Match both shapes.
--
-- Balances otherwise follow BillingService exactly -- voided out, outstanding out,
-- superseded rows out (BALANCE_FLOOR_JOIN / NOT_SUPERSEDED, billing.service.ts:179-210).
WITH bf AS (
SELECT t.id, t.customerId, t.transactionDate
FROM transactions t
LEFT JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.voidedAt IS NULL
AND (
tt.nameEn = 'BALANCE FORWARD'
OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1
AND t.legacySourceTable = 'datos2')
)
),
bfloor AS (
SELECT customerId, MAX(transactionDate) AS floorDate FROM bf GROUP BY customerId
),
real_tx AS (
SELECT t.* FROM transactions t
WHERE t.voidedAt IS NULL AND t.id NOT IN (SELECT id FROM bf)
),
pool AS (
SELECT CAST(r.legacyId AS UNSIGNED) AS numid,
c.id AS cid,
REPLACE(REPLACE(COALESCE(c.name,''),'\n',' '),'\t',' ') AS name,
IF(c.archivedAt IS NULL,0,1) AS archived,
IF(c.email IS NULL OR c.email='',0,1) AS hasEmail
FROM customer_legacy_refs r
JOIN customers c ON c.id = r.customerId
WHERE r.sourceSystem='utilities' AND r.sourceTable='DATGRAL'
)
SELECT
p.numid, p.cid AS customerUuid, p.name, p.archived, p.hasEmail,
-- EXISTS, not a join: 16 customers hold more than one insurance ref (several
-- insurance rows folded into one customer), and joining them fans this result
-- out past one row per NUMid — 1,188 rows for a 1,171-id pool.
EXISTS(SELECT 1 FROM customer_legacy_refs i
WHERE i.customerId=p.cid AND i.sourceSystem='insurance') AS insRef,
COALESCE((SELECT ROUND(SUM(t.amount),2) FROM transactions t
LEFT JOIN bfloor f ON f.customerId=t.customerId
WHERE t.customerId=p.cid AND t.voidedAt IS NULL AND t.outstanding=0
AND t.currency='MXN'
AND (f.floorDate IS NULL OR t.transactionDate>=f.floorDate)),0) AS balMxn,
COALESCE((SELECT ROUND(SUM(t.amount),2) FROM transactions t
LEFT JOIN bfloor f ON f.customerId=t.customerId
WHERE t.customerId=p.cid AND t.voidedAt IS NULL AND t.outstanding=0
AND t.currency='USD'
AND (f.floorDate IS NULL OR t.transactionDate>=f.floorDate)),0) AS balUsd,
(SELECT COUNT(*) FROM transactions t
WHERE t.customerId=p.cid AND t.voidedAt IS NULL AND t.outstanding=1) AS nopago,
(SELECT COUNT(*) FROM real_tx t WHERE t.customerId=p.cid) AS realTx,
(SELECT COUNT(*) FROM real_tx t WHERE t.customerId=p.cid
AND t.transactionDate >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)) AS realTx12m,
(SELECT COUNT(*) FROM real_tx t WHERE t.customerId=p.cid
AND t.transactionDate >= DATE_SUB(CURDATE(), INTERVAL 36 MONTH)) AS realTx36m,
(SELECT DATE(MAX(t.transactionDate)) FROM real_tx t WHERE t.customerId=p.cid) AS lastRealTx,
(SELECT COUNT(*) FROM properties pr WHERE pr.customerId=p.cid AND pr.archivedAt IS NULL) AS props,
-- services are counted BOTH ways: an inactive service is still a record of the id
-- having been used, so the auto tier requires zero of any kind.
(SELECT COUNT(*) FROM property_services ps JOIN properties pr ON pr.id=ps.propertyId
WHERE pr.customerId=p.cid AND pr.archivedAt IS NULL) AS anySvc,
(SELECT COUNT(*) FROM property_services ps JOIN properties pr ON pr.id=ps.propertyId
WHERE pr.customerId=p.cid AND pr.archivedAt IS NULL AND ps.active=1) AS activeSvc,
(SELECT COUNT(*) FROM policies po WHERE po.customerId=p.cid AND po.archivedAt IS NULL
AND (po.policyTo IS NULL OR po.policyTo >= CURDATE())) AS activePol,
(SELECT COUNT(*) FROM policies po WHERE po.customerId=p.cid AND po.archivedAt IS NULL) AS anyPol,
(SELECT COUNT(*) FROM vehicles v WHERE v.customerId=p.cid) AS veh,
(SELECT COUNT(*) FROM trust_accounts ta JOIN properties pr ON pr.id=ta.propertyId
WHERE pr.customerId=p.cid) AS trust,
(SELECT COUNT(*) FROM statement_documents s WHERE s.matchedCustomerId=p.cid) AS stmt,
(SELECT COUNT(*) FROM policy_ocr_documents o WHERE o.matchedCustomerId=p.cid) AS ocr,
(SELECT COUNT(*) FROM email_notification_log e WHERE e.customerId=p.cid) AS enl,
(SELECT COUNT(*) FROM email_log e WHERE e.customerId=p.cid) AS elog,
(SELECT COUNT(*) FROM account_status_history a WHERE a.customerId=p.cid) AS ash
FROM pool p
ORDER BY p.numid;