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:update"
|
||||
| "customer:delete"
|
||||
| "customer:portal-access"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
@@ -48,6 +49,12 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
"customer:create": "STAFF",
|
||||
"customer:update": "STAFF",
|
||||
"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:update": "STAFF",
|
||||
"policy:delete": "MANAGER",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
|
||||
@@ -24,6 +25,7 @@ import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
export class CustomersController {
|
||||
constructor(
|
||||
private readonly customers: CustomersService,
|
||||
private readonly numids: NumidService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@@ -36,6 +38,13 @@ export class CustomersController {
|
||||
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()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@@ -95,4 +104,27 @@ export class CustomersController {
|
||||
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
||||
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 { SettingsModule } from "../settings/settings.module";
|
||||
import { CustomersController } from "./customers.controller";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
|
||||
@Module({
|
||||
imports: [SettingsModule],
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
providers: [CustomersService, NumidService],
|
||||
exports: [NumidService],
|
||||
})
|
||||
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",
|
||||
/** JSON cadence of the automatic pólizas renewal sweep. */
|
||||
schedulePolizas: "notification.schedule.polizas",
|
||||
/** Whether the NUMid allocator may reuse empty portal ids. */
|
||||
numidRecycleEmpty: "numid.recycleEmpty",
|
||||
} as const;
|
||||
|
||||
/** 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) {
|
||||
return this.prisma.appSetting.findUnique({ where: { key } });
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ContextReports } from "@/components/ContextReports";
|
||||
import {
|
||||
archiveCustomer,
|
||||
getCustomer,
|
||||
grantPortalAccess,
|
||||
policyDocumentDownloadUrl,
|
||||
propertyDocumentDownloadUrl,
|
||||
restoreCustomer,
|
||||
@@ -151,9 +152,43 @@ function CustomerActions({
|
||||
}) {
|
||||
const canEdit = useCan("customer:update");
|
||||
const canDelete = useCan("customer:delete");
|
||||
const canGrantPortal = useCan("customer:portal-access");
|
||||
const [busy, setBusy] = useState(false);
|
||||
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() {
|
||||
const verb = archived ? "restaurar" : "archivar";
|
||||
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 (
|
||||
<div className="row-actions">
|
||||
{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 && (
|
||||
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
||||
Editar
|
||||
|
||||
@@ -232,6 +232,20 @@ export function restoreCustomer(id: string): Promise<CustomerDetail> {
|
||||
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 */
|
||||
|
||||
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Ability =
|
||||
| "customer:create"
|
||||
| "customer:update"
|
||||
| "customer:delete"
|
||||
| "customer:portal-access"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "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.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.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.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 |
|
||||
@@ -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
|
||||
(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.
|
||||
`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
|
||||
|
||||
@@ -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