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:
@@ -21,6 +21,7 @@ export type Ability =
|
|||||||
| "customer:create"
|
| "customer:create"
|
||||||
| "customer:update"
|
| "customer:update"
|
||||||
| "customer:delete"
|
| "customer:delete"
|
||||||
|
| "customer:portal-access"
|
||||||
| "policy:create"
|
| "policy:create"
|
||||||
| "policy:update"
|
| "policy:update"
|
||||||
| "policy:delete"
|
| "policy:delete"
|
||||||
@@ -48,6 +49,12 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
|||||||
"customer:create": "STAFF",
|
"customer:create": "STAFF",
|
||||||
"customer:update": "STAFF",
|
"customer:update": "STAFF",
|
||||||
"customer:delete": "ADMIN",
|
"customer:delete": "ADMIN",
|
||||||
|
// Assigning a portal NUMid is granting someone the ability to log in to
|
||||||
|
// my.jorgecuadros.com and read an account, so it sits above customer:update:
|
||||||
|
// editing a phone number is the day job, handing out portal identity is not.
|
||||||
|
// It is also close to irreversible in practice — the id is what the customer
|
||||||
|
// then types at every login.
|
||||||
|
"customer:portal-access": "MANAGER",
|
||||||
"policy:create": "STAFF",
|
"policy:create": "STAFF",
|
||||||
"policy:update": "STAFF",
|
"policy:update": "STAFF",
|
||||||
"policy:delete": "MANAGER",
|
"policy:delete": "MANAGER",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { AbilityGuard } from "../auth/ability.guard";
|
|||||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
import { AuditService } from "../common/audit.service";
|
import { AuditService } from "../common/audit.service";
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { NumidService } from "./numid.service";
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ import { UpdateCustomerDto } from "./update-customer.dto";
|
|||||||
export class CustomersController {
|
export class CustomersController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly customers: CustomersService,
|
private readonly customers: CustomersService,
|
||||||
|
private readonly numids: NumidService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -36,6 +38,13 @@ export class CustomersController {
|
|||||||
return this.customers.stats();
|
return this.customers.stats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reusable portal ids, lowest first. Declared above `:id` so the literal
|
||||||
|
* path is not swallowed by the wildcard route. */
|
||||||
|
@Get("numid/candidates")
|
||||||
|
async numidCandidates() {
|
||||||
|
return { candidates: await this.numids.emptyCandidates() };
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(
|
list(
|
||||||
@Query("query") query?: string,
|
@Query("query") query?: string,
|
||||||
@@ -95,4 +104,27 @@ export class CustomersController {
|
|||||||
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give this customer a portal NUMid so they can log in to
|
||||||
|
* my.jorgecuadros.com. Idempotent — a customer who already has one gets it
|
||||||
|
* back rather than a second identity.
|
||||||
|
*/
|
||||||
|
@Post(":id/portal-access")
|
||||||
|
@RequireAbility("customer:portal-access")
|
||||||
|
async portalAccess(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const allocation = await this.numids.allocate(id);
|
||||||
|
if (allocation.origin !== "existing") {
|
||||||
|
// Logged with the origin and the previous holder: a recycled id is the one
|
||||||
|
// case where reading this record later has to answer "whose number was
|
||||||
|
// this before, and was it taken or minted".
|
||||||
|
void this.audit.log(this.actingId(req), "customer.portal-access", {
|
||||||
|
customerId: id,
|
||||||
|
numid: allocation.numid,
|
||||||
|
origin: allocation.origin,
|
||||||
|
previousCustomerId: allocation.previousCustomerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return allocation;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { SettingsModule } from "../settings/settings.module";
|
||||||
import { CustomersController } from "./customers.controller";
|
import { CustomersController } from "./customers.controller";
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { NumidService } from "./numid.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [SettingsModule],
|
||||||
controllers: [CustomersController],
|
controllers: [CustomersController],
|
||||||
providers: [CustomersService],
|
providers: [CustomersService, NumidService],
|
||||||
|
exports: [NumidService],
|
||||||
})
|
})
|
||||||
export class CustomersModule {}
|
export class CustomersModule {}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { ConflictException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { NumidService } from "./numid.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What matters about the allocator is the two things it must never do: hand the
|
||||||
|
* same id to two customers, and hand out a recycled id while Access can still
|
||||||
|
* take it back. Both are tested here; the emptiness SQL itself is exercised
|
||||||
|
* against real data by scripts/numid-audit.mjs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
existingRef?: { legacyId: string } | null;
|
||||||
|
archived?: boolean;
|
||||||
|
missing?: boolean;
|
||||||
|
recycle?: boolean;
|
||||||
|
empty?: { numid: string; refId: string; customerId: string }[];
|
||||||
|
max?: number | null;
|
||||||
|
/** Make the first N create() calls fail the unique key, as a race would. */
|
||||||
|
createConflicts?: number;
|
||||||
|
/** Make updateMany report "nothing matched", as a lost recycle race would. */
|
||||||
|
recycleMisses?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(opts: Options = {}) {
|
||||||
|
const created: { legacyId: string }[] = [];
|
||||||
|
let conflictsLeft = opts.createConflicts ?? 0;
|
||||||
|
let missesLeft = opts.recycleMisses ?? 0;
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(
|
||||||
|
opts.missing ? null : { id: "cust-new", archivedAt: opts.archived ? new Date() : null },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
customerLegacyRef: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(opts.existingRef ?? null),
|
||||||
|
updateMany: jest.fn().mockImplementation(() => {
|
||||||
|
if (missesLeft > 0) {
|
||||||
|
missesLeft -= 1;
|
||||||
|
return Promise.resolve({ count: 0 });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ count: 1 });
|
||||||
|
}),
|
||||||
|
create: jest.fn().mockImplementation(({ data }: { data: { legacyId: string } }) => {
|
||||||
|
if (conflictsLeft > 0) {
|
||||||
|
conflictsLeft -= 1;
|
||||||
|
return Promise.reject(
|
||||||
|
new Prisma.PrismaClientKnownRequestError("dup", {
|
||||||
|
code: "P2002",
|
||||||
|
clientVersion: "5",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
created.push(data);
|
||||||
|
return Promise.resolve(data);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Two different raw queries share one mock: the MAX lookup returns a single
|
||||||
|
// {max} row, everything else is the empty-candidate list.
|
||||||
|
$queryRaw: jest.fn().mockImplementation((sql: { strings?: string[]; sql?: string }) => {
|
||||||
|
const text = String((sql as unknown as { sql?: string }).sql ?? "");
|
||||||
|
if (text.includes("MAX(")) return Promise.resolve([{ max: opts.max ?? null }]);
|
||||||
|
return Promise.resolve(opts.empty ?? []);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
numidRecycleEmpty: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ value: opts.recycle ?? false, source: "default" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new NumidService(prisma as never, settings as never),
|
||||||
|
prisma,
|
||||||
|
created,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NUMid allocation", () => {
|
||||||
|
it("returns the id a customer already holds instead of minting a second one", async () => {
|
||||||
|
// A double-clicked button must not fork the customer's portal identity.
|
||||||
|
const { service, prisma } = build({ existingRef: { legacyId: "501" } });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||||
|
numid: "501",
|
||||||
|
origin: "existing",
|
||||||
|
});
|
||||||
|
expect(prisma.customerLegacyRef.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allocates one past the highest id in the pool", async () => {
|
||||||
|
const { service, created } = build({ max: 1171 });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
expect(created[0]).toMatchObject({
|
||||||
|
sourceSystem: "utilities",
|
||||||
|
sourceTable: "DATGRAL",
|
||||||
|
legacyId: "1172",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts at 1 when the pool is empty", async () => {
|
||||||
|
const { service } = build({ max: null });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT recycle while the setting is off, even with candidates free", async () => {
|
||||||
|
// The default has to be the safe one: every reusable id still exists in
|
||||||
|
// Access, and a --sync run reassigns it back to its Access owner.
|
||||||
|
const { service, prisma } = build({
|
||||||
|
max: 1171,
|
||||||
|
empty: [{ numid: "1089", refId: "ref-1089", customerId: "cust-old" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
expect(prisma.customerLegacyRef.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the lowest empty id once recycling is switched on", async () => {
|
||||||
|
const { service, prisma } = build({
|
||||||
|
recycle: true,
|
||||||
|
max: 1171,
|
||||||
|
empty: [
|
||||||
|
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||||
|
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||||
|
numid: "1089",
|
||||||
|
origin: "recycled",
|
||||||
|
previousCustomerId: "cust-old",
|
||||||
|
});
|
||||||
|
// Guarded on the owner read a moment ago, so a ref that moved underneath us
|
||||||
|
// matches nothing rather than being stolen.
|
||||||
|
expect(prisma.customerLegacyRef.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: "ref-1089", customerId: "cust-old" },
|
||||||
|
data: { customerId: "cust-new" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a candidate that someone else took first", async () => {
|
||||||
|
const { service } = build({
|
||||||
|
recycle: true,
|
||||||
|
max: 1171,
|
||||||
|
recycleMisses: 1,
|
||||||
|
empty: [
|
||||||
|
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||||
|
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1094",
|
||||||
|
origin: "recycled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a new id when recycling is on but nothing is free", async () => {
|
||||||
|
const { service } = build({ recycle: true, max: 1171, empty: [] });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries when two writers pick the same id", async () => {
|
||||||
|
// The unique key on (sourceSystem, sourceTable, legacyId) is what decides
|
||||||
|
// the winner; the loser must retry, never overwrite.
|
||||||
|
const { service, prisma } = build({ max: 1171, createConflicts: 1 });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
expect(prisma.customerLegacyRef.create).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up loudly rather than looping forever", async () => {
|
||||||
|
const { service } = build({ max: 1171, createConflicts: 99 });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an archived customer", async () => {
|
||||||
|
const { service } = build({ archived: true });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a customer that does not exist", async () => {
|
||||||
|
const { service } = build({ missing: true });
|
||||||
|
|
||||||
|
await expect(service.allocate("nope")).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { SettingsService } from "../settings/settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocation of the portal NUMid — the "Security Number" my.jorgecuadros.com
|
||||||
|
* asks for at login.
|
||||||
|
*
|
||||||
|
* The NUMid is not a column on `Customer`. It is a `CustomerLegacyRef` row with
|
||||||
|
* (sourceSystem='utilities', sourceTable='DATGRAL'), and `CustomersService.create`
|
||||||
|
* deliberately writes none: a natively created customer has no legacy provenance.
|
||||||
|
* The consequence is that every customer created in the staff UI is invisible to
|
||||||
|
* the portal until this service gives them an id.
|
||||||
|
*
|
||||||
|
* WHY THIS IS NOT DONE AT CREATE TIME. Insurance is expected to move to the
|
||||||
|
* platform before utilities, and an insurance-only customer has no reason to hold
|
||||||
|
* a portal identity. Allocating on every create would spend utilities ids — and
|
||||||
|
* the handful of reusable ones — on people who will never log in. So this is an
|
||||||
|
* explicit staff action instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The pair that identifies a portal NUMid. */
|
||||||
|
export const UTILITIES_SYSTEM = "utilities";
|
||||||
|
export const UTILITIES_TABLE = "DATGRAL";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* insurance/DATGRAL is a SEPARATE id space that reuses the same sourceTable name
|
||||||
|
* and runs past 4,000. It must never be read as a NUMid, and never allocated
|
||||||
|
* from: the portal cannot resolve those ids. Every query here filters on BOTH
|
||||||
|
* columns for that reason, never on sourceTable alone.
|
||||||
|
*/
|
||||||
|
const POOL = {
|
||||||
|
sourceSystem: UTILITIES_SYSTEM,
|
||||||
|
sourceTable: UTILITIES_TABLE,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AllocationOrigin = "existing" | "new" | "recycled";
|
||||||
|
|
||||||
|
export interface Allocation {
|
||||||
|
numid: string;
|
||||||
|
origin: AllocationOrigin;
|
||||||
|
/** Set only on a recycle — the customer the id was taken from. */
|
||||||
|
previousCustomerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUMids that were created and never used, safe for an allocator to take.
|
||||||
|
*
|
||||||
|
* THE TWO OBVIOUS RULES BOTH FIND NOTHING, which is why this one looks the way
|
||||||
|
* it does. "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 in the current year. That row has to be subtracted before any
|
||||||
|
* activity test means anything, which is what `bf` does below.
|
||||||
|
*
|
||||||
|
* The balance-forward row is matched in two shapes on purpose.
|
||||||
|
* transform_transactions.py:120 mints a type literally named 'BALANCE FORWARD';
|
||||||
|
* databases loaded before that change carry the same rows with typeId NULL,
|
||||||
|
* dated Jan 1, legacySourceTable='datos2'. Matching only the type name floors
|
||||||
|
* nothing on such a database and turns the balance test into a raw lifetime sum
|
||||||
|
* — the double-count that read the whole book as +20.6M MXN in credit before
|
||||||
|
* d173c9e, and which here would mark live customers as empty.
|
||||||
|
*
|
||||||
|
* Services are tested as "any service" rather than "any ACTIVE service": a
|
||||||
|
* deactivated water account is still a record of somebody having lived behind
|
||||||
|
* this id.
|
||||||
|
*
|
||||||
|
* Kept in step with scripts/numid-audit.sql, which reports the same tier for a
|
||||||
|
* human. That script is the reporting copy of this rule; change both together.
|
||||||
|
*/
|
||||||
|
const EMPTY_NUMID_SQL = Prisma.sql`
|
||||||
|
WITH bf AS (
|
||||||
|
SELECT t.id, t.customerId
|
||||||
|
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')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT r.legacyId AS numid, r.id AS refId, r.customerId AS customerId
|
||||||
|
FROM customer_legacy_refs r
|
||||||
|
JOIN customers c ON c.id = r.customerId
|
||||||
|
WHERE r.sourceSystem = ${UTILITIES_SYSTEM} AND r.sourceTable = ${UTILITIES_TABLE}
|
||||||
|
AND (c.email IS NULL OR c.email = '')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM transactions t
|
||||||
|
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||||
|
AND t.id NOT IN (SELECT id FROM bf))
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM transactions t
|
||||||
|
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.outstanding = 1)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM property_services ps
|
||||||
|
JOIN properties p ON p.id = ps.propertyId WHERE p.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM policies p WHERE p.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM vehicles v WHERE v.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM trust_accounts ta
|
||||||
|
JOIN properties p ON p.id = ta.propertyId WHERE p.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM statement_documents s WHERE s.matchedCustomerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM policy_ocr_documents o WHERE o.matchedCustomerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM email_notification_log e WHERE e.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM email_log e WHERE e.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM account_status_history a WHERE a.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM customer_legacy_refs i
|
||||||
|
WHERE i.customerId = c.id AND i.sourceSystem = 'insurance')
|
||||||
|
ORDER BY CAST(r.legacyId AS UNSIGNED)`;
|
||||||
|
|
||||||
|
interface EmptyRow {
|
||||||
|
numid: string;
|
||||||
|
refId: string;
|
||||||
|
customerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NumidService {
|
||||||
|
private readonly logger = new Logger(NumidService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly settings: SettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The customer's portal id, or null if they have none. */
|
||||||
|
async current(customerId: string): Promise<string | null> {
|
||||||
|
const ref = await this.prisma.customerLegacyRef.findFirst({
|
||||||
|
where: { customerId, ...POOL },
|
||||||
|
select: { legacyId: true },
|
||||||
|
});
|
||||||
|
return ref?.legacyId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reusable ids, lowest first. Empty unless recycling is switched on. */
|
||||||
|
async emptyCandidates(): Promise<string[]> {
|
||||||
|
const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
|
||||||
|
return rows.map((r) => r.numid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a customer a portal NUMid.
|
||||||
|
*
|
||||||
|
* Idempotent: a customer who already holds one gets it back rather than a
|
||||||
|
* second id, so a double-clicked button cannot fork an identity.
|
||||||
|
*/
|
||||||
|
async allocate(customerId: string): Promise<Allocation> {
|
||||||
|
const customer = await this.prisma.customer.findUnique({
|
||||||
|
where: { id: customerId },
|
||||||
|
select: { id: true, archivedAt: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||||
|
if (customer.archivedAt) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"No se puede asignar un número de portal a un cliente archivado",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.current(customerId);
|
||||||
|
if (existing) return { numid: existing, origin: "existing" };
|
||||||
|
|
||||||
|
const recycle = await this.recycleEnabled();
|
||||||
|
|
||||||
|
// Two writers can pick the same id between the read and the write. The
|
||||||
|
// unique key on (sourceSystem, sourceTable, legacyId) is what actually
|
||||||
|
// decides the winner; the loser retries and takes the next id rather than
|
||||||
|
// silently overwriting. Bounded so a genuinely wedged pool fails loudly.
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
if (recycle) {
|
||||||
|
const recycled = await this.tryRecycle(customerId);
|
||||||
|
if (recycled) return recycled;
|
||||||
|
}
|
||||||
|
return await this.allocateNext(customerId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUniqueViolation(error)) throw error;
|
||||||
|
this.logger.warn(
|
||||||
|
`NUMid allocation for ${customerId} lost a race (attempt ${attempt + 1}), retrying`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ConflictException(
|
||||||
|
"No se pudo asignar un número de portal; intente de nuevo",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the recycle tier is live.
|
||||||
|
*
|
||||||
|
* Off by default, and that default is the safe one while Access is still the
|
||||||
|
* utilities master. Every id in the pool ALSO exists in Access DATGRAL, and a
|
||||||
|
* `--sync` migration run upserts refs with ON DUPLICATE KEY UPDATE customerId
|
||||||
|
* (transform_customers.py:327) — so an id recycled today is silently handed
|
||||||
|
* back to its Access owner on the next sync, and the customer who was given it
|
||||||
|
* loses their portal identity. Turn this on once utilities has cut over, or
|
||||||
|
* for ids that have been deleted at the source.
|
||||||
|
*/
|
||||||
|
private async recycleEnabled(): Promise<boolean> {
|
||||||
|
const { value } = await this.settings.numidRecycleEmpty();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-point the lowest empty id at this customer. Null when none is free. */
|
||||||
|
private async tryRecycle(customerId: string): Promise<Allocation | null> {
|
||||||
|
const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
|
||||||
|
for (const row of rows) {
|
||||||
|
// Guarded by the owner we just read: if anything moved the ref in the
|
||||||
|
// meantime the update matches nothing and we fall through to the next
|
||||||
|
// candidate rather than stealing an id that is no longer empty.
|
||||||
|
const moved = await this.prisma.customerLegacyRef.updateMany({
|
||||||
|
where: { id: row.refId, customerId: row.customerId },
|
||||||
|
data: { customerId },
|
||||||
|
});
|
||||||
|
if (moved.count === 1) {
|
||||||
|
this.logger.log(
|
||||||
|
`NUMid ${row.numid} recycled from ${row.customerId} to ${customerId}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
numid: row.numid,
|
||||||
|
origin: "recycled",
|
||||||
|
previousCustomerId: row.customerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One past the highest id in the pool. */
|
||||||
|
private async allocateNext(customerId: string): Promise<Allocation> {
|
||||||
|
const [{ max }] = await this.prisma.$queryRaw<{ max: number | null }[]>(
|
||||||
|
// MAX over a CAST, not over the string: legacyId is VARCHAR, so a plain
|
||||||
|
// MAX returns '999' as the highest of 1,171 rows and the allocator hands
|
||||||
|
// out an id that is already taken.
|
||||||
|
Prisma.sql`SELECT MAX(CAST(legacyId AS UNSIGNED)) AS max
|
||||||
|
FROM customer_legacy_refs
|
||||||
|
WHERE sourceSystem = ${UTILITIES_SYSTEM} AND sourceTable = ${UTILITIES_TABLE}`,
|
||||||
|
);
|
||||||
|
const numid = String(Number(max ?? 0) + 1);
|
||||||
|
await this.prisma.customerLegacyRef.create({
|
||||||
|
data: { customerId, ...POOL, legacyId: numid },
|
||||||
|
});
|
||||||
|
return { numid, origin: "new" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ export const SETTING_KEYS = {
|
|||||||
scheduleServicios: "notification.schedule.servicios",
|
scheduleServicios: "notification.schedule.servicios",
|
||||||
/** JSON cadence of the automatic pólizas renewal sweep. */
|
/** JSON cadence of the automatic pólizas renewal sweep. */
|
||||||
schedulePolizas: "notification.schedule.polizas",
|
schedulePolizas: "notification.schedule.polizas",
|
||||||
|
/** Whether the NUMid allocator may reuse empty portal ids. */
|
||||||
|
numidRecycleEmpty: "numid.recycleEmpty",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Where a resolved value came from. Shown in the UI. */
|
/** Where a resolved value came from. Shown in the UI. */
|
||||||
@@ -169,6 +171,39 @@ export class SettingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the NUMid allocator may reuse empty portal ids instead of only
|
||||||
|
* issuing new ones.
|
||||||
|
*
|
||||||
|
* Defaults to OFF, and the default is the safety property rather than a
|
||||||
|
* preference: while Access remains the utilities master, every reusable id
|
||||||
|
* still exists in DATGRAL, and a `--sync` migration run reassigns the ref back
|
||||||
|
* to its Access owner (transform_customers.py:327). Recycling before utilities
|
||||||
|
* cuts over therefore hands out ids that quietly stop working. No env rung —
|
||||||
|
* this has never been an environment variable and should be flipped
|
||||||
|
* deliberately, in the UI, by someone who knows the cutover happened.
|
||||||
|
*/
|
||||||
|
async numidRecycleEmpty(): Promise<ResolvedSetting<boolean>> {
|
||||||
|
const row = await this.read(SETTING_KEYS.numidRecycleEmpty);
|
||||||
|
if (row) {
|
||||||
|
return {
|
||||||
|
value: row.value === "true",
|
||||||
|
source: "db",
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
updatedById: row.updatedById,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { value: false, source: "default", updatedAt: null, updatedById: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setNumidRecycleEmpty(
|
||||||
|
enabled: boolean,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ResolvedSetting<boolean>> {
|
||||||
|
await this.write(SETTING_KEYS.numidRecycleEmpty, String(enabled), userId);
|
||||||
|
return this.numidRecycleEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
private read(key: string) {
|
private read(key: string) {
|
||||||
return this.prisma.appSetting.findUnique({ where: { key } });
|
return this.prisma.appSetting.findUnique({ where: { key } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ContextReports } from "@/components/ContextReports";
|
|||||||
import {
|
import {
|
||||||
archiveCustomer,
|
archiveCustomer,
|
||||||
getCustomer,
|
getCustomer,
|
||||||
|
grantPortalAccess,
|
||||||
policyDocumentDownloadUrl,
|
policyDocumentDownloadUrl,
|
||||||
propertyDocumentDownloadUrl,
|
propertyDocumentDownloadUrl,
|
||||||
restoreCustomer,
|
restoreCustomer,
|
||||||
@@ -151,9 +152,43 @@ function CustomerActions({
|
|||||||
}) {
|
}) {
|
||||||
const canEdit = useCan("customer:update");
|
const canEdit = useCan("customer:update");
|
||||||
const canDelete = useCan("customer:delete");
|
const canDelete = useCan("customer:delete");
|
||||||
|
const canGrantPortal = useCan("customer:portal-access");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const archived = customer.archivedAt != null;
|
const archived = customer.archivedAt != null;
|
||||||
|
|
||||||
|
// The portal NUMid is a legacy ref, not a column: (utilities, DATGRAL) is the
|
||||||
|
// "Security Number" my.jorgecuadros.com asks for. An insurance ref is a
|
||||||
|
// different id space entirely and does not let anyone log in, so both columns
|
||||||
|
// are checked — matching on sourceTable alone would hide the button from
|
||||||
|
// customers who cannot actually reach the portal.
|
||||||
|
const hasPortalId = customer.legacyRefs.some(
|
||||||
|
(r) => r.sourceSystem === "utilities" && r.sourceTable === "DATGRAL",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function grantPortal() {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"¿Asignar un número de portal a este cliente? Con él podrá entrar a " +
|
||||||
|
"my.jorgecuadros.com.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const { numid, origin } = await grantPortalAccess(customer.id);
|
||||||
|
window.alert(
|
||||||
|
origin === "existing"
|
||||||
|
? `Este cliente ya tenía el número de portal ${numid}.`
|
||||||
|
: `Número de portal asignado: ${numid}.`,
|
||||||
|
);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleArchive() {
|
async function toggleArchive() {
|
||||||
const verb = archived ? "restaurar" : "archivar";
|
const verb = archived ? "restaurar" : "archivar";
|
||||||
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
|
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
|
||||||
@@ -169,11 +204,23 @@ function CustomerActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canEdit && !canDelete) return null;
|
const showPortal = canGrantPortal && !hasPortalId && !archived;
|
||||||
|
if (!canEdit && !canDelete && !showPortal) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
{archived && <span className="badge badge-negative">Archivado</span>}
|
{archived && <span className="badge badge-negative">Archivado</span>}
|
||||||
|
{showPortal && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={grantPortal}
|
||||||
|
disabled={busy}
|
||||||
|
title="Asigna el número que el cliente usa para entrar al portal"
|
||||||
|
>
|
||||||
|
Habilitar acceso al portal
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
||||||
Editar
|
Editar
|
||||||
|
|||||||
@@ -232,6 +232,20 @@ export function restoreCustomer(id: string): Promise<CustomerDetail> {
|
|||||||
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
|
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NumidAllocation {
|
||||||
|
numid: string;
|
||||||
|
/** "existing" when the customer already had one — the call is idempotent. */
|
||||||
|
origin: "existing" | "new" | "recycled";
|
||||||
|
previousCustomerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give a customer the portal NUMid they log in to my.jorgecuadros.com with. */
|
||||||
|
export function grantPortalAccess(id: string): Promise<NumidAllocation> {
|
||||||
|
return apiFetch<NumidAllocation>(`/customers/${id}/portal-access`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------ Policies module */
|
/* ------------------------------------------------------ Policies module */
|
||||||
|
|
||||||
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type Ability =
|
|||||||
| "customer:create"
|
| "customer:create"
|
||||||
| "customer:update"
|
| "customer:update"
|
||||||
| "customer:delete"
|
| "customer:delete"
|
||||||
|
| "customer:portal-access"
|
||||||
| "policy:create"
|
| "policy:create"
|
||||||
| "policy:update"
|
| "policy:update"
|
||||||
| "policy:delete"
|
| "policy:delete"
|
||||||
|
|||||||
+27
-1
@@ -83,7 +83,7 @@ guess.
|
|||||||
| 1.3 | Carrier API **direction**: outbound quote/issue (ANA supports today) or inbound portfolio sync (no evidence either carrier offers it) | whether §4 is buildable at all | INSURANCE §4 |
|
| 1.3 | Carrier API **direction**: outbound quote/issue (ANA supports today) or inbound portfolio sync (no evidence either carrier offers it) | whether §4 is buildable at all | INSURANCE §4 |
|
||||||
| 1.4 | CFE amount: the rounded barcode figure (`$268`, what is paid at the window) or the exact breakdown total (`$268.88`) | the parser currently takes the barcode | STATEMENT_OCR / RECEIPT §2 |
|
| 1.4 | CFE amount: the rounded barcode figure (`$268`, what is paid at the window) or the exact breakdown total (`$268.88`) | the parser currently takes the barcode | STATEMENT_OCR / RECEIPT §2 |
|
||||||
| 1.5 | The Seguros USD bank's name, currency and details | multi-bank is built; that account does not exist yet | RECEIPT §3 |
|
| 1.5 | The Seguros USD bank's name, currency and details | multi-bank is built; that account does not exist yet | RECEIPT §3 |
|
||||||
| 1.6 | Recycling triggers — exact "1 year inactive" / "cancelled" definitions, and whether recycling ever means true data purge | §4 recycling | RECEIPT §4 |
|
| 1.6 | Whether recycling ever means a true data purge. The *triggers* are now settled and built (see §5 "NUMid allocation"); what is still open is whether a recycled id's old rows are ever deleted rather than left attached to the previous customer | nothing — the allocator ships without a purge | RECEIPT §4 |
|
||||||
| 1.7 | Notice body in Spanish or English | `Customer` carries no language preference | INSURANCE §1 |
|
| 1.7 | Notice body in Spanish or English | `Customer` carries no language preference | INSURANCE §1 |
|
||||||
| 1.8 | How to model `TRASPASOS PAYPAL` — a clearing account, not a customer, carrying −7.03M MXN over 309 movements and therefore topping the adeudo worklist | deliberately not special-cased in code | RESUME §6 |
|
| 1.8 | How to model `TRASPASOS PAYPAL` — a clearing account, not a customer, carrying −7.03M MXN over 309 movements and therefore topping the adeudo worklist | deliberately not special-cased in code | RESUME §6 |
|
||||||
| 1.9 | The 78 policyholders with no email — skip silently or produce a print worklist | recommendation is the worklist | INSURANCE §1 |
|
| 1.9 | The 78 policyholders with no email — skip silently or produce a print worklist | recommendation is the worklist | INSURANCE §1 |
|
||||||
@@ -184,6 +184,32 @@ Each of these is a known, deliberate stopping point rather than a bug.
|
|||||||
- Handwritten folder numbers are deliberately not an input to matching
|
- Handwritten folder numbers are deliberately not an input to matching
|
||||||
(Tesseract read `405` as `205`).
|
(Tesseract read `405` as `205`).
|
||||||
|
|
||||||
|
**NUMid allocation** — `POST /customers/:id/portal-access` assigns the portal
|
||||||
|
"Security Number", on a staff action rather than at create time, because an
|
||||||
|
insurance-only customer has no reason to hold a utilities id.
|
||||||
|
|
||||||
|
- **Recycling is built but switched off.** `numid.recycleEmpty` in `app_settings`
|
||||||
|
defaults to false, and that default is a safety property, not a preference:
|
||||||
|
every reusable id still exists in Access DATGRAL, and a `--sync` migration run
|
||||||
|
upserts refs with `ON DUPLICATE KEY UPDATE customerId`
|
||||||
|
(`transform_customers.py:327`), so an id recycled today is handed back to its
|
||||||
|
Access owner on the next sync and the customer given it loses portal access.
|
||||||
|
**Flip it on after utilities cuts over**, or for ids deleted at the source.
|
||||||
|
- **A full re-import destroys every natively allocated id.**
|
||||||
|
`transform_customers.py:246` truncates `customers` and `customer_legacy_refs`,
|
||||||
|
then rebuilds the pool from Access alone. Until that is fixed — either by
|
||||||
|
mandating `--sync` for all future utilities loads, or by teaching the transform
|
||||||
|
to preserve refs with no Access counterpart — a NUMid issued here survives only
|
||||||
|
until the next full load. This is a prerequisite for the cutover, not a
|
||||||
|
nice-to-have.
|
||||||
|
- **The empty-id rule exists twice**: enforced in `numid.service.ts`
|
||||||
|
(`EMPTY_NUMID_SQL`) and reported by `scripts/numid-audit.sql`. They agree today
|
||||||
|
(both return 1089, 1094, 1134, 1143 on dev); they are not mechanically kept in
|
||||||
|
step, so change them together.
|
||||||
|
- **No un-assign.** Nothing removes a NUMid once given, and nothing reports which
|
||||||
|
ids were recycled from whom beyond the `customer.portal-access` activity-log
|
||||||
|
entry.
|
||||||
|
|
||||||
**Bank** — the concept→ramo classifier is **won't-build**, not pending.
|
**Bank** — the concept→ramo classifier is **won't-build**, not pending.
|
||||||
`concepto` is a payee name (0 of 22,354 match a category) and TABLA RAMODOS is
|
`concepto` is a payee name (0 of 22,354 match a category) and TABLA RAMODOS is
|
||||||
a property-management expense chart, not the business-line split it was assumed
|
a property-management expense chart, not the business-line split it was assumed
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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