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,149 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* NUMid recycle audit.
|
||||
*
|
||||
* Prints which portal NUMids (customer_legacy_refs, utilities/DATGRAL) are dead
|
||||
* enough to hand to a new customer, and which are merely quiet. Read-only: it
|
||||
* writes nothing and reassigns nothing.
|
||||
*
|
||||
* node scripts/numid-audit.mjs # summary + both candidate tiers
|
||||
* node scripts/numid-audit.mjs --csv # full per-NUMid table on stdout
|
||||
* node scripts/numid-audit.mjs --numid 501
|
||||
*
|
||||
* Needs DATABASE_URL. Run it against PROD before acting on anything: the tiers
|
||||
* describe whatever database it is pointed at, and a stale copy will happily
|
||||
* report a NUMid as empty that prod has been billing all year.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// Relative, not "@jorgecuadros/database": the workspace link is not always
|
||||
// present at the repo root, and this script has to run from a bare checkout and
|
||||
// from inside the API container alike.
|
||||
import pkg from "../packages/database/generated/client/index.js";
|
||||
|
||||
const { PrismaClient } = pkg;
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Anything here means the id carries history a new owner would inherit.
|
||||
* Counted, not sampled: a single row in any of them disqualifies.
|
||||
*/
|
||||
const HISTORY_COLUMNS = [
|
||||
"veh",
|
||||
"trust",
|
||||
"stmt",
|
||||
"ocr",
|
||||
"enl",
|
||||
"elog",
|
||||
"ash",
|
||||
"nopago",
|
||||
];
|
||||
|
||||
const n = (v) => (v == null ? 0 : Number(v));
|
||||
const hasHistory = (r) => HISTORY_COLUMNS.some((c) => n(r[c]) > 0);
|
||||
const zeroBalance = (r) => n(r.balMxn) === 0 && n(r.balUsd) === 0;
|
||||
|
||||
/**
|
||||
* EMPTY — the id was created and never used. Safe for an allocator to take
|
||||
* without a human looking, subject to the legacy check below.
|
||||
*
|
||||
* "Never transacted" means zero movements once the synthetic opening-balance row
|
||||
* is removed; that row exists for all 1,171 NUMids and is not evidence of use.
|
||||
* Services are checked as anySvc rather than activeSvc, because a deactivated
|
||||
* water account still says a person once lived behind this id.
|
||||
*/
|
||||
const isEmpty = (r) =>
|
||||
n(r.realTx) === 0 &&
|
||||
zeroBalance(r) &&
|
||||
n(r.anySvc) === 0 &&
|
||||
n(r.anyPol) === 0 &&
|
||||
n(r.insRef) === 0 &&
|
||||
n(r.hasEmail) === 0 &&
|
||||
!hasHistory(r);
|
||||
|
||||
/**
|
||||
* DORMANT — used once, quiet for years, owes nothing. NOT auto-allocatable:
|
||||
* a returning snowbird is indistinguishable from an abandoned account here.
|
||||
*/
|
||||
const isDormant = (r) =>
|
||||
!isEmpty(r) &&
|
||||
n(r.realTx36m) === 0 &&
|
||||
zeroBalance(r) &&
|
||||
n(r.activeSvc) === 0 &&
|
||||
n(r.activePol) === 0 &&
|
||||
!hasHistory(r);
|
||||
|
||||
function line(r) {
|
||||
return (
|
||||
` ${String(r.numid).padStart(5)} ${(r.name || "(sin nombre)").slice(0, 30).padEnd(30)}` +
|
||||
` last=${(r.lastRealTx ? new Date(r.lastRealTx).toISOString().slice(0, 10) : "never").padStart(10)}` +
|
||||
` tx=${String(n(r.realTx)).padStart(3)}` +
|
||||
` bal=${n(r.balMxn).toFixed(2).padStart(10)}` +
|
||||
` svc=${n(r.anySvc)}` +
|
||||
` pol=${n(r.anyPol)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const sql = readFileSync(join(HERE, "numid-audit.sql"), "utf8");
|
||||
const rows = await prisma.$queryRawUnsafe(sql);
|
||||
|
||||
const one = args.indexOf("--numid");
|
||||
if (one !== -1) {
|
||||
const want = Number(args[one + 1]);
|
||||
const r = rows.find((x) => Number(x.numid) === want);
|
||||
if (!r) {
|
||||
console.log(`NUMid ${want} is not in the utilities/DATGRAL pool.`);
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify(r, (_k, v) => (typeof v === "bigint" ? Number(v) : v), 2));
|
||||
console.log(
|
||||
`\nverdict: ${isEmpty(r) ? "EMPTY" : isDormant(r) ? "DORMANT" : "IN USE"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.includes("--csv")) {
|
||||
const cols = Object.keys(rows[0]);
|
||||
console.log(cols.join(","));
|
||||
for (const r of rows) {
|
||||
console.log(cols.map((c) => JSON.stringify(r[c] ?? "")).join(","));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const empty = rows.filter(isEmpty);
|
||||
const dormant = rows.filter(isDormant);
|
||||
const max = rows.reduce((m, r) => Math.max(m, Number(r.numid)), 0);
|
||||
|
||||
console.log(`pool: ${rows.length} NUMids, max ${max}`);
|
||||
console.log(` EMPTY (never used, auto-allocatable): ${empty.length}`);
|
||||
console.log(` DORMANT (quiet, needs a human): ${dormant.length}`);
|
||||
console.log(` IN USE: ${rows.length - empty.length - dormant.length}`);
|
||||
|
||||
console.log("\nEMPTY");
|
||||
empty.forEach((r) => console.log(line(r)));
|
||||
console.log("\nDORMANT");
|
||||
dormant.forEach((r) => console.log(line(r)));
|
||||
|
||||
console.log(
|
||||
"\nNOTE: this audit sees the platform only. Every NUMid here also exists in\n" +
|
||||
"Access, and freakma republishes DATGRAL in full on each export, so an id\n" +
|
||||
"reassigned here comes back under its old owner unless it is removed at the\n" +
|
||||
"source or the NUMid is routed to the platform. Confirm against prod\n" +
|
||||
"datosfreak before reassigning.",
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user