diff --git a/apps/api/src/customers/create-customer.dto.ts b/apps/api/src/customers/create-customer.dto.ts index fbc349b..d6f0b19 100644 --- a/apps/api/src/customers/create-customer.dto.ts +++ b/apps/api/src/customers/create-customer.dto.ts @@ -2,9 +2,11 @@ import { IsBoolean, IsEmail, IsEnum, + IsInt, IsNumber, IsOptional, IsString, + Min, MinLength, } from "class-validator"; import { Currency } from "@jorgecuadros/database"; @@ -19,6 +21,8 @@ export class CreateCustomerDto { @MinLength(1) name!: string; + /** Shelf folder number; null clears it. Unique among non-archived customers. */ + @IsOptional() @IsInt() @Min(1) folderNumber?: number | null; @IsOptional() @IsString() addressLine1?: string; @IsOptional() @IsString() addressLine2?: string; @IsOptional() @IsString() city?: string; diff --git a/apps/api/src/customers/customer-folder.spec.ts b/apps/api/src/customers/customer-folder.spec.ts new file mode 100644 index 0000000..0c62b68 --- /dev/null +++ b/apps/api/src/customers/customer-folder.spec.ts @@ -0,0 +1,71 @@ +import { ConflictException } from "@nestjs/common"; +import { CustomersService } from "./customers.service"; + +/** + * A folder number belongs to one open account at a time, and an archived + * customer's number is history, not a claim. The nextFolderNumber SQL is + * checked against real data, not here. + */ + +function build(opts: { holder?: { name: string } | null; archived?: boolean; folder?: number | null }) { + const prisma = { + customer: { + findFirst: jest.fn().mockResolvedValue(opts.holder ?? null), + findUnique: jest.fn().mockResolvedValue({ + id: "c1", + archivedAt: opts.archived ? new Date() : null, + folderNumber: opts.folder ?? null, + }), + create: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)), + }, + }; + return { service: new CustomersService(prisma as never), prisma }; +} + +describe("customer folder numbers", () => { + it("refuses a number an open account already holds", async () => { + const { service, prisma } = build({ holder: { name: "SWEET, DONALD E." } }); + + await expect(service.create({ name: "Nuevo", folderNumber: 214 })).rejects.toThrow( + new ConflictException("La carpeta 214 ya la tiene SWEET, DONALD E."), + ); + expect(prisma.customer.create).not.toHaveBeenCalled(); + // Only open accounts count as holders. + expect(prisma.customer.findFirst.mock.calls[0][0].where).toMatchObject({ + folderNumber: 214, + archivedAt: null, + }); + }); + + it("does not count the customer being edited as the holder", async () => { + const { service, prisma } = build({}); + + await service.update("c1", { folderNumber: 214 }); + expect(prisma.customer.findFirst.mock.calls[0][0].where).toMatchObject({ + id: { not: "c1" }, + }); + }); + + it("lets an archived customer keep a number someone else now holds", async () => { + const { service, prisma } = build({ archived: true, holder: { name: "X" } }); + + await expect(service.update("c1", { folderNumber: 214 })).resolves.toBeDefined(); + expect(prisma.customer.findFirst).not.toHaveBeenCalled(); + }); + + it("will not reopen an account whose folder was handed out meanwhile", async () => { + const { service, prisma } = build({ archived: true, folder: 214, holder: { name: "X" } }); + + await expect(service.restore("c1")).rejects.toBeInstanceOf(ConflictException); + expect(prisma.customer.update).not.toHaveBeenCalled(); + }); + + it("skips the check when no number is given", async () => { + const { service, prisma } = build({ holder: { name: "X" } }); + + await service.create({ name: "Nuevo" }); + await service.update("c1", { folderNumber: null }); + expect(prisma.customer.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/customers/customers.controller.ts b/apps/api/src/customers/customers.controller.ts index a169f69..38b8670 100644 --- a/apps/api/src/customers/customers.controller.ts +++ b/apps/api/src/customers/customers.controller.ts @@ -45,6 +45,12 @@ export class CustomersController { return { candidates: await this.numids.emptyCandidates() }; } + /** Suggested folder number for a new customer. Above `:id` like the rest. */ + @Get("folder/next") + async nextFolder() { + return { folderNumber: await this.customers.nextFolderNumber() }; + } + @Get() list( @Query("query") query?: string, diff --git a/apps/api/src/customers/customers.service.ts b/apps/api/src/customers/customers.service.ts index 1af0101..4880ec4 100644 --- a/apps/api/src/customers/customers.service.ts +++ b/apps/api/src/customers/customers.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { ConflictException, Injectable, NotFoundException } from "@nestjs/common"; import { Prisma } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; import { CreateCustomerDto } from "./create-customer.dto"; @@ -68,6 +68,7 @@ export class CustomersService { { mobile: { contains: q } }, { city: { contains: q } }, { legacyRefs: { some: { legacyId: { contains: q } } } }, + ...(/^\d+$/.test(q) ? [{ folderNumber: Number(q) }] : []), ]; } if (line === "utility") where.properties = { some: {} }; @@ -90,6 +91,7 @@ export class CustomersService { id: true, name: true, nameSource: true, + folderNumber: true, city: true, state: true, email: true, @@ -106,6 +108,7 @@ export class CustomersService { id: r.id, name: r.name, nameSource: r.nameSource, + folderNumber: r.folderNumber, city: r.city, state: r.state, email: r.email, @@ -248,6 +251,7 @@ export class CustomersService { } async create(dto: CreateCustomerDto) { + await this.assertFolderFree(dto.folderNumber); return this.prisma.customer.create({ // App-created rows: nameMissing false (name is required), no legacy // provenance — those columns stay null, marking a native record. @@ -256,7 +260,9 @@ export class CustomersService { } async update(id: string, dto: UpdateCustomerDto) { - await this.ensureExists(id); + const current = await this.ensureExists(id); + // An archived customer's number is history, not a claim on the shelf. + if (!current.archivedAt) await this.assertFolderFree(dto.folderNumber, id); return this.prisma.customer.update({ where: { id }, data: this.toData(dto) }); } @@ -270,7 +276,9 @@ export class CustomersService { } async restore(id: string) { - await this.ensureExists(id); + const current = await this.ensureExists(id); + // Its folder may have been handed to someone else while it was closed. + await this.assertFolderFree(current.folderNumber, id); return this.prisma.customer.update({ where: { id }, data: { archivedAt: null }, @@ -280,9 +288,46 @@ export class CustomersService { private async ensureExists(id: string) { const found = await this.prisma.customer.findUnique({ where: { id }, - select: { id: true }, + select: { id: true, archivedAt: true, folderNumber: true }, }); if (!found) throw new NotFoundException(`Customer ${id} not found`); + return found; + } + + // --- folder numbers --------------------------------------------------------- + + /** + * A folder number belongs to one open account at a time. Archived customers + * keep theirs as a record of which folder they used, without holding it. + * + * ponytail: check-then-write, not a constraint — two staff saving the same + * number in the same instant both win. A functional unique index on + * IF(archivedAt IS NULL, folderNumber, NULL) closes it, if Prisma ever models one. + */ + private async assertFolderFree(folderNumber: number | null | undefined, exceptId?: string) { + if (folderNumber == null) return; + const holder = await this.prisma.customer.findFirst({ + where: { folderNumber, archivedAt: null, ...(exceptId ? { id: { not: exceptId } } : {}) }, + select: { name: true }, + }); + if (holder) { + throw new ConflictException(`La carpeta ${folderNumber} ya la tiene ${holder.name}`); + } + } + + /** Lowest folder number no open account holds — how the office reuses them. */ + async nextFolderNumber(): Promise { + const [row] = await this.prisma.$queryRaw<{ n: bigint | number }[]>` + SELECT MIN(c.n) AS n FROM ( + SELECT 1 AS n + UNION SELECT folderNumber + 1 FROM customers + WHERE archivedAt IS NULL AND folderNumber IS NOT NULL + ) c + WHERE c.n NOT IN ( + SELECT folderNumber FROM customers + WHERE archivedAt IS NULL AND folderNumber IS NOT NULL + )`; + return Number(row.n); } /** Top-line counts for a dashboard header. */ diff --git a/apps/api/src/customers/update-customer.dto.ts b/apps/api/src/customers/update-customer.dto.ts index 3d796b4..12fac8f 100644 --- a/apps/api/src/customers/update-customer.dto.ts +++ b/apps/api/src/customers/update-customer.dto.ts @@ -2,9 +2,11 @@ import { IsBoolean, IsEmail, IsEnum, + IsInt, IsNumber, IsOptional, IsString, + Min, MinLength, } from "class-validator"; import { Currency } from "@jorgecuadros/database"; @@ -12,6 +14,7 @@ import { Currency } from "@jorgecuadros/database"; /** Same editable fields as create, all optional. */ export class UpdateCustomerDto { @IsOptional() @IsString() @MinLength(1) name?: string; + @IsOptional() @IsInt() @Min(1) folderNumber?: number | null; @IsOptional() @IsString() addressLine1?: string; @IsOptional() @IsString() addressLine2?: string; @IsOptional() @IsString() city?: string; diff --git a/apps/api/src/reports/reports.registry.ts b/apps/api/src/reports/reports.registry.ts index 54cc583..9c8d9b6 100644 --- a/apps/api/src/reports/reports.registry.ts +++ b/apps/api/src/reports/reports.registry.ts @@ -23,10 +23,7 @@ import { notCashJournal, periodSourceTable, } from "../billing/billing.service"; -import { - UTILITIES_SYSTEM, - UTILITIES_TABLE, -} from "../customers/numid.service"; +import { UTILITIES_TABLE } from "../customers/numid.service"; import type { PrismaService } from "../prisma/prisma.service"; import { CONCEPT_BY_KIND } from "../statements/statements.service"; import { @@ -50,8 +47,8 @@ function nameOf(c: { name: string; nameMissing: boolean }): string { const isoDay = (d: Date | null | undefined) => d ? d.toISOString().slice(0, 10) : "—"; -/** Every report leads with the legacy customer number (2026-10 meeting). */ -const NUMID_COLUMN: ColumnDef = { key: "numid", label: "NUMid", type: "text" }; +/** Every report leads with the customer's shelf folder number (2026-10 meeting). */ +const FOLDER_COLUMN: ColumnDef = { key: "folder", label: "Carpeta", type: "text" }; /** Policy reports add the insurance file number beside it. */ const INSURANCE_NO_COLUMN: ColumnDef = { key: "insuranceNo", @@ -60,38 +57,42 @@ const INSURANCE_NO_COLUMN: ColumnDef = { }; /** - * Legacy customer numbers for a page of report rows. The office files, - * captures and talks in NUMids, not platform ids. + * Customer numbers for a page of report rows. The office files, captures and + * talks in folder numbers, not platform ids. * - * The utilities NUMid is the portal number, one per customer. insurance/DATGRAL - * is a separate id space under the same table name, so it is read on its own - * and never shown as a NUMid; 16 customers hold more than one. + * The insurance file number is the legacy insurance/DATGRAL id, a separate id + * space under the same table name as the utilities NUMid; 16 customers hold + * more than one. */ async function legacyNumbers(prisma: PrismaService, customerIds: string[]) { const ids = [...new Set(customerIds)]; - const refs = ids.length - ? await prisma.customerLegacyRef.findMany({ - where: { - customerId: { in: ids }, - sourceTable: UTILITIES_TABLE, - sourceSystem: { in: [UTILITIES_SYSTEM, "insurance"] }, - }, - select: { customerId: true, sourceSystem: true, legacyId: true }, - }) - : []; - const byCustomer = new Map(); + const [customers, refs] = ids.length + ? await Promise.all([ + prisma.customer.findMany({ + where: { id: { in: ids } }, + select: { id: true, folderNumber: true }, + }), + prisma.customerLegacyRef.findMany({ + where: { + customerId: { in: ids }, + sourceTable: UTILITIES_TABLE, + sourceSystem: "insurance", + }, + select: { customerId: true, legacyId: true }, + }), + ]) + : [[], []]; + const folders = new Map(customers.map((c) => [c.id, c.folderNumber])); + const insurance = new Map(); for (const r of refs) { - const e = byCustomer.get(r.customerId) ?? { insurance: [] }; - if (r.sourceSystem === UTILITIES_SYSTEM) e.numid = r.legacyId; - else e.insurance.push(r.legacyId); - byCustomer.set(r.customerId, e); + insurance.set(r.customerId, [...(insurance.get(r.customerId) ?? []), r.legacyId]); } return { - numid: (id: string) => byCustomer.get(id)?.numid ?? "—", + folder: (id: string) => String(folders.get(id) ?? "—"), insurance: (id: string) => - byCustomer + insurance .get(id) - ?.insurance.sort((a, b) => Number(a) - Number(b)) + ?.sort((a, b) => Number(a) - Number(b)) .join(", ") || "—", }; } @@ -182,7 +183,7 @@ const listadoEnRojo: ReportDef = { }, ], columns: [ - NUMID_COLUMN, + FOLDER_COLUMN, { key: "name", label: "Cliente", type: "text" }, { key: "city", label: "Ciudad", type: "text" }, { key: "lastMovement", label: "Último movimiento", type: "date" }, @@ -258,7 +259,7 @@ const listadoEnRojo: ReportDef = { const b = r.balance ?? new Prisma.Decimal(0); totalBalance = totalBalance.plus(b); return { - numid: nums.numid(r.id), + folder: nums.folder(r.id), name: nameOf(r), city: r.city ?? "—", lastMovement: isoDay(r.lastMovement), @@ -348,7 +349,7 @@ const pagosNoEfectuados: ReportDef = { }, ], columns: [ - NUMID_COLUMN, + FOLDER_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" }, { key: "accountNumber", label: "Cuenta", type: "text" }, @@ -418,7 +419,7 @@ const pagosNoEfectuados: ReportDef = { const out = rows.map((r) => { if (Number(r.unfunded)) unfunded++; return { - numid: nums.numid(r.customerId), + folder: nums.folder(r.customerId), customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }), propertyAddress: r.addresses ?? "—", accountNumber: r.accounts ?? "—", @@ -481,7 +482,7 @@ const faltantes: ReportDef = { }, ], columns: [ - NUMID_COLUMN, + FOLDER_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" }, { key: "missing", label: "Faltante", type: "text" }, @@ -525,7 +526,7 @@ const faltantes: ReportDef = { const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId)); const out = rows.map((r) => ({ - numid: nums.numid(r.customerId), + folder: nums.folder(r.customerId), customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }), propertyAddress: r.propertyAddress ?? "—", missing: r.missing, @@ -571,7 +572,7 @@ const reporteDeEfectivo: ReportDef = { ], columns: [ { key: "date", label: "Fecha", type: "date" }, - NUMID_COLUMN, + FOLDER_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "concept", label: "Concepto", type: "text" }, { key: "source", label: "Origen", type: "text" }, @@ -630,7 +631,7 @@ const reporteDeEfectivo: ReportDef = { total = total.plus(r.amount); return { date: r.transactionDate.toISOString().slice(0, 10), - numid: nums.numid(r.customer.id), + folder: nums.folder(r.customer.id), customerName: nameOf(r.customer), concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—", source: r.legacySourceTable ?? "—", @@ -695,7 +696,7 @@ const vigente: ReportDef = { ], columns: [ { key: "policyNumber", label: "Póliza", type: "text" }, - NUMID_COLUMN, + FOLDER_COLUMN, INSURANCE_NO_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" }, @@ -743,7 +744,7 @@ const vigente: ReportDef = { if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); return { policyNumber: r.policyNumber, - numid: nums.numid(r.customer.id), + folder: nums.folder(r.customer.id), insuranceNo: nums.insurance(r.customer.id), customerName: nameOf(r.customer), provider: r.insuranceProvider?.name ?? "—", @@ -848,7 +849,7 @@ const avisoRenovacion: ReportDef = { // ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement". columns: [ { key: "policyNumber", label: "Póliza", type: "text" }, - NUMID_COLUMN, + FOLDER_COLUMN, INSURANCE_NO_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" }, @@ -888,7 +889,7 @@ const avisoRenovacion: ReportDef = { if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); const letter = { ...toRenewalLetterRow(r, generation), - numid: nums.numid(r.customer.id), + folder: nums.folder(r.customer.id), insuranceNo: nums.insurance(r.customer.id), }; if (letter.sentAt) sentCount++; @@ -968,7 +969,7 @@ const edoCuentaDatos: ReportDef = { }, }); if (!customer) return { rows: [], subtitle: "Cliente no encontrado" }; - const numid = (await legacyNumbers(prisma, [customer.id])).numid(customer.id); + const folder = (await legacyNumbers(prisma, [customer.id])).folder(customer.id); // The source-table exclusion, the balance floor and the year scope below // are BillingService.statement's, because this report and @@ -1133,7 +1134,7 @@ const edoCuentaDatos: ReportDef = { { __kind: "header", name: nameOf(customer), - numid, + folder, address: customer.addressLine1 ?? "", city: [customer.city, customer.state].filter(Boolean).join(", "), phone: customer.phone ?? "", @@ -1151,7 +1152,7 @@ const edoCuentaDatos: ReportDef = { ...carried, ...movements, ], - subtitle: `NUMid ${numid} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`, + subtitle: `Carpeta ${folder} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`, }; }, }; @@ -1189,7 +1190,7 @@ const chequeCount: ReportDef = { }, ], columns: [ - NUMID_COLUMN, + FOLDER_COLUMN, { key: "customerName", label: "Cliente", type: "text" }, { key: "reference", label: "Referencia", type: "text" }, { key: "period", label: "Periodo", type: "text" }, @@ -1248,7 +1249,7 @@ const chequeCount: ReportDef = { const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id)); return { rows: rows.map((r) => ({ - numid: nums.numid(r.customer.id), + folder: nums.folder(r.customer.id), customerName: nameOf(r.customer), reference: r.reference ?? "—", period: r.period ?? "—", diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx index 00d8f31..456b129 100644 --- a/apps/web/src/app/clientes/[id]/page.tsx +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -256,6 +256,7 @@ function Hero({ .join(" · "); const facts: { label: string; value: string }[] = [ + { label: "Carpeta", value: data.folderNumber != null ? String(data.folderNumber) : "—" }, { label: "Cliente desde", value: formatDate(data.customerSince) }, { label: "Cuota", diff --git a/apps/web/src/app/clientes/page.tsx b/apps/web/src/app/clientes/page.tsx index af89f0b..eacda3c 100644 --- a/apps/web/src/app/clientes/page.tsx +++ b/apps/web/src/app/clientes/page.tsx @@ -125,7 +125,7 @@ function ClientesBrowser() { type="search" value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Buscar por nombre, ciudad, teléfono…" + placeholder="Buscar por nombre, carpeta, ciudad, teléfono…" aria-label="Buscar clientes" /> @@ -261,6 +261,8 @@ function CustomerRow({ c }: { c: CustomerListItem }) { )}
+ {c.folderNumber != null && Carpeta {c.folderNumber}} + {c.folderNumber != null && (location || contact) && ·} {location && {location}} {location && contact && ·} {contact && {contact}} diff --git a/apps/web/src/components/CustomerForm.tsx b/apps/web/src/components/CustomerForm.tsx index 500dac8..cae4bcc 100644 --- a/apps/web/src/components/CustomerForm.tsx +++ b/apps/web/src/components/CustomerForm.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import type { CustomerDetail, CustomerInput, Currency } from "@/lib/types"; -import { createCustomer, updateCustomer } from "@/lib/api"; +import { createCustomer, getNextFolderNumber, updateCustomer } from "@/lib/api"; /** ISO date (yyyy-mm-dd) for a date input, from an API date string. */ function toDateInput(v: string | null | undefined): string { @@ -20,6 +20,7 @@ function numOrUndef(v: string | number | null | undefined): number | undefined { type Values = { name: string; + folderNumber: string; addressLine1: string; addressLine2: string; city: string; @@ -45,6 +46,7 @@ type Values = { function initial(c?: CustomerDetail): Values { return { name: c?.name ?? "", + folderNumber: c?.folderNumber != null ? String(c.folderNumber) : "", addressLine1: c?.addressLine1 ?? "", addressLine2: c?.addressLine2 ?? "", city: c?.city ?? "", @@ -85,6 +87,17 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + // A new customer is offered the lowest free folder, the way the office + // hands numbers back out; staff can type any other. + useEffect(() => { + if (editing) return; + getNextFolderNumber() + .then(({ folderNumber }) => + setV((prev) => (prev.folderNumber ? prev : { ...prev, folderNumber: String(folderNumber) })), + ) + .catch(() => {}); + }, [editing]); + function set(key: K, val: Values[K]) { setV((prev) => ({ ...prev, [key]: val })); } @@ -95,6 +108,8 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) { setError(null); const payload: CustomerInput = { name: v.name.trim(), + // Blank clears it on edit; s() would leave the old number in place. + folderNumber: v.folderNumber.trim() === "" ? (editing ? null : undefined) : Number(v.folderNumber), addressLine1: s(v.addressLine1), addressLine2: s(v.addressLine2), city: s(v.city), @@ -138,6 +153,10 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) { set("name", e.target.value)} /> + + set("folderNumber", e.target.value)} /> + set("email", e.target.value)} /> diff --git a/apps/web/src/components/ReportRunner.tsx b/apps/web/src/components/ReportRunner.tsx index fc8d4c2..dacf034 100644 --- a/apps/web/src/components/ReportRunner.tsx +++ b/apps/web/src/components/ReportRunner.tsx @@ -390,8 +390,8 @@ function StatementLayout({ result }: { result: ReportRunResult }) {

{String(header.name ?? "—")}

- {header.numid && header.numid !== "—" ? ( -

NUMid {String(header.numid)}

+ {header.folder && header.folder !== "—" ? ( +

Carpeta {String(header.folder)}

) : null} {Boolean(header.address) && (

{String(header.address)}

diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0f0a806..98179a2 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -215,6 +215,11 @@ export function listCustomers( return apiFetch(`/customers${qs ? `?${qs}` : ""}`); } +/** Lowest folder number no open account holds — the suggestion for a new customer. */ +export function getNextFolderNumber(): Promise<{ folderNumber: number }> { + return apiFetch<{ folderNumber: number }>("/customers/folder/next"); +} + export function getCustomer(id: string): Promise { return apiFetch(`/customers/${id}`); } diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 9331ccc..626b886 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -220,6 +220,7 @@ export interface CustomerListItem { name: string; /** Legacy table the name was recovered from; null when it came from DATGRAL. */ nameSource: string | null; + folderNumber: number | null; city: string | null; state: string | null; email: string | null; @@ -1113,6 +1114,7 @@ export interface CustomerDetail { id: string; name: string; nameSource: string | null; + folderNumber: number | null; addressLine1: string | null; addressLine2: string | null; city: string | null; @@ -1146,6 +1148,8 @@ export interface CustomerDetail { /** Editable customer fields — shared by the create/edit form and the API. */ export interface CustomerInput { name: string; + /** null clears it. */ + folderNumber?: number | null; addressLine1?: string; addressLine2?: string; city?: string; diff --git a/migration/transform_customers.py b/migration/transform_customers.py index 15dfb3e..626f7dc 100644 --- a/migration/transform_customers.py +++ b/migration/transform_customers.py @@ -166,12 +166,17 @@ def resolve_name(raw, nid, index) -> tuple[str, str | None]: # ------------------------------- record builders ---------------------------- # def customer_from_utilities(row, name_index) -> dict: - name, name_source = resolve_name(row["nombre"], norm_id(row["num_id"]), name_index) + nid = norm_id(row["num_id"]) + name, name_source = resolve_name(row["nombre"], nid, name_index) return dict( id=str(uuid.uuid4()), name=name, nameSource=name_source, nameMissing=int(name == NO_NAME), + # The NUMid is the number on the customer's shelf folder. It is copied + # into a plain label so the office can reuse it without it ever being + # an identity; customer_legacy_refs keeps the NUMid itself. + folderNumber=int(nid) if nid and nid.isdigit() else None, addressLine1=s(row["direccion"]), addressLine2=s(row["colonia"]), city=s(row["ciudad"]), @@ -205,6 +210,7 @@ def customer_from_insurance(row, name_index) -> dict: name=name, nameSource=name_source, nameMissing=int(name == NO_NAME), + folderNumber=None, # insurance ids are a separate space, not a shelf folder addressLine1=s(row["direccion_1"]), addressLine2=s(row["direccion_2"]), city=s(row["ciudad"]), @@ -228,7 +234,7 @@ def customer_from_insurance(row, name_index) -> dict: _CUST_COLS = [ - "id", "name", "nameSource", "nameMissing", "addressLine1", "addressLine2", "city", "state", "zipCode", + "id", "name", "nameSource", "nameMissing", "folderNumber", "addressLine1", "addressLine2", "city", "state", "zipCode", "country", "phone", "mobile", "fax", "email", "notes", "identificationType", "identificationNumber", "identificationExpiration", "customerSince", "status", "feeAmount", "minimumBalance", "updatedAt", @@ -322,7 +328,7 @@ def main() -> None: remap[rec["id"]] = stable or rec["id"] for rec in customers: rec["id"] = remap[rec["id"]] - cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),minimumBalance=VALUES(minimumBalance),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS)) + cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),folderNumber=VALUES(folderNumber),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),minimumBalance=VALUES(minimumBalance),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS)) for ref in refs: cur.execute("INSERT INTO customer_legacy_refs (id,customerId,sourceSystem,sourceTable,legacyId) VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId)", (ref[0], remap[ref[1]], ref[2], ref[3], ref[4])) @@ -374,6 +380,12 @@ def main() -> None: recovered = cur.fetchall() cur.execute("SELECT COUNT(*) FROM customers WHERE name = %s", (NO_NAME,)) still_unnamed = cur.fetchone()[0] + cur.execute("SELECT COUNT(folderNumber) FROM customers") + with_folder = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM (SELECT folderNumber FROM customers " + "WHERE archivedAt IS NULL AND folderNumber IS NOT NULL " + "GROUP BY folderNumber HAVING COUNT(*) > 1) d") + shared_folders = cur.fetchone()[0] print("=== Customer load complete ===") print(f" utilities DATGRAL rows : {len(util)}") @@ -390,6 +402,8 @@ def main() -> None: print(f" from {src:16} : {n}") print(f" of which via the linked insurance record : {from_ins_side}") print(f" still {NO_NAME} : {still_unnamed}") + print(f" folder numbers : {with_folder} (one per numeric utilities NUMid)") + print(f" held by >1 open customer (must be 0) : {shared_folders}") if not sync_mode: # Full-load invariants only: sync upserts into an already-loaded (and # pruned) table, so these exact counts don't hold. diff --git a/packages/database/prisma/migrations/20261012120000_customer_folder_number/migration.sql b/packages/database/prisma/migrations/20261012120000_customer_folder_number/migration.sql new file mode 100644 index 0000000..8a19c0a --- /dev/null +++ b/packages/database/prisma/migrations/20261012120000_customer_folder_number/migration.sql @@ -0,0 +1,20 @@ +-- The office files every customer in a numbered paper folder and hands the +-- number back out once an account is closed. That number has been the legacy +-- utilities NUMid; it becomes a plain label here so reusing it never touches +-- an identity. Unique among non-archived customers only, enforced by the API. +ALTER TABLE `customers` ADD COLUMN `folderNumber` INTEGER NULL; + +CREATE INDEX `customers_folderNumber_idx` ON `customers`(`folderNumber`); + +-- Existing customers start with their utilities NUMid. Insurance-only +-- customers have none and get one from the office. +UPDATE `customers` c +JOIN ( + SELECT `customerId`, MIN(CAST(`legacyId` AS UNSIGNED)) AS n + FROM `customer_legacy_refs` + WHERE `sourceSystem` = 'utilities' + AND `sourceTable` = 'DATGRAL' + AND `legacyId` REGEXP '^[0-9]+$' + GROUP BY `customerId` +) r ON r.`customerId` = c.`id` +SET c.`folderNumber` = r.n; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index dd796a1..09a81d3 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -89,6 +89,13 @@ model Customer { // can sort nameless records last — ordering by `name` alone puts them first, // since "(" sorts before every letter. nameMissing Boolean @default(false) + // The number on the customer's paper folder on the office shelf. A label, + // not an identity: nothing joins on it. The office hands a number back out + // once an account is closed, so it is unique only among customers that are + // not archived — enforced by CustomersService, not by an index, because + // Prisma cannot model a partial/functional unique and would drop one. + // Imported customers start with their utilities NUMid. + folderNumber Int? addressLine1 String? addressLine2 String? city String? @@ -128,6 +135,7 @@ model Customer { emailNotificationLogs EmailNotificationLog[] accountStatusHistory AccountStatusHistory[] + @@index([folderNumber]) @@map("customers") }