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>
256 lines
10 KiB
TypeScript
256 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. A customer can also hold
|
|
* more than one insurance ref — 16 of them do, where several insurance rows
|
|
* folded into one customer — so those are tested with EXISTS rather than joined.
|
|
*/
|
|
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"
|
|
);
|
|
}
|