feat(customers): allocate portal NUMids, with an audit for reusable ones
Customers created in the staff UI had no NUMid and so could not log in to my.jorgecuadros.com at all: the id is a CustomerLegacyRef row, not a column, and create() deliberately writes none. Allocation is a staff action (POST /customers/:id/portal-access, MANAGER) rather than part of create, because insurance is expected to move to the platform before utilities and an insurance-only customer has no reason to spend a utilities id. The audit that decides which ids are reusable took three passes. "Owns no rows" matches nobody -- migration gave all 1,171 NUMids a property and a transaction. "No transaction in N years" also matches nobody -- every customer carries a synthetic Jan-1 opening-balance row, so everyone looks active this year. Subtracting that row is what makes dormancy measurable, and it leaves 4 never-used ids and 10 dormant ones on dev. Two further traps are encoded in the queries: insurance/DATGRAL is a separate id space that reuses the sourceTable name and runs past 4,000, and ACCOUNT CANCELED is a transaction line type, not an account state -- all 8 customers carrying it have current-year activity. Recycling ships switched off (numid.recycleEmpty, default false). Every reusable id still exists in Access DATGRAL, and a --sync run reassigns refs with ON DUPLICATE KEY UPDATE customerId, so an id recycled before the utilities cutover is silently handed back to its Access owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
-- 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: one customer can hold several insurance refs (DATGRAL and
|
||||
-- COBRO3 both), and joining them fans this result out past one row per NUMid.
|
||||
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;
|
||||
Reference in New Issue
Block a user