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>
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
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"
|
|
);
|
|
}
|