feat(customers): a shelf folder number the office can hand back out
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m42s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s

The office files every customer in a numbered paper folder and reuses the
number once an account closes. That number was the legacy NUMid, which is
also an import key and the portal login, so reusing it meant re-pointing an
identity. It is now customers.folderNumber: a plain label nothing joins on.

- Unique only among non-archived customers, checked in CustomersService
  (create, update, restore). Archived customers keep theirs as history.
- GET /customers/folder/next suggests the lowest free number; the new
  customer form pre-fills it.
- Backfilled from the utilities NUMid (dev: 1,171 folders, 0 shared); the
  importer copies it on load. Insurance-only customers start without one.
- Reports, the printed statement and the customer file show "Carpeta"
  instead of NUMid. The list searches by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rmancinasandClaude Opus 5.5 committed 2026-10-12 19:04:01 -07:00
1 parent 031b13f8c8
commit b016e53893
15 files changed
+257 -54

No files matched your search

@@ -2,9 +2,11 @@ import {
IsBoolean, IsBoolean,
IsEmail, IsEmail,
IsEnum, IsEnum,
IsInt,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Min,
MinLength, MinLength,
} from "class-validator"; } from "class-validator";
import { Currency } from "@jorgecuadros/database"; import { Currency } from "@jorgecuadros/database";
@@ -19,6 +21,8 @@ export class CreateCustomerDto {
@MinLength(1) @MinLength(1)
name!: string; 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() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string; @IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() city?: string; @IsOptional() @IsString() city?: string;
@@ -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();
});
});
@@ -45,6 +45,12 @@ export class CustomersController {
return { candidates: await this.numids.emptyCandidates() }; 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() @Get()
list( list(
@Query("query") query?: string, @Query("query") query?: string,
+49 -4
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database"; import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { CreateCustomerDto } from "./create-customer.dto"; import { CreateCustomerDto } from "./create-customer.dto";
@@ -68,6 +68,7 @@ export class CustomersService {
{ mobile: { contains: q } }, { mobile: { contains: q } },
{ city: { contains: q } }, { city: { contains: q } },
{ legacyRefs: { some: { legacyId: { contains: q } } } }, { legacyRefs: { some: { legacyId: { contains: q } } } },
...(/^\d+$/.test(q) ? [{ folderNumber: Number(q) }] : []),
]; ];
} }
if (line === "utility") where.properties = { some: {} }; if (line === "utility") where.properties = { some: {} };
@@ -90,6 +91,7 @@ export class CustomersService {
id: true, id: true,
name: true, name: true,
nameSource: true, nameSource: true,
folderNumber: true,
city: true, city: true,
state: true, state: true,
email: true, email: true,
@@ -106,6 +108,7 @@ export class CustomersService {
id: r.id, id: r.id,
name: r.name, name: r.name,
nameSource: r.nameSource, nameSource: r.nameSource,
folderNumber: r.folderNumber,
city: r.city, city: r.city,
state: r.state, state: r.state,
email: r.email, email: r.email,
@@ -248,6 +251,7 @@ export class CustomersService {
} }
async create(dto: CreateCustomerDto) { async create(dto: CreateCustomerDto) {
await this.assertFolderFree(dto.folderNumber);
return this.prisma.customer.create({ return this.prisma.customer.create({
// App-created rows: nameMissing false (name is required), no legacy // App-created rows: nameMissing false (name is required), no legacy
// provenance — those columns stay null, marking a native record. // provenance — those columns stay null, marking a native record.
@@ -256,7 +260,9 @@ export class CustomersService {
} }
async update(id: string, dto: UpdateCustomerDto) { 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) }); return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
} }
@@ -270,7 +276,9 @@ export class CustomersService {
} }
async restore(id: string) { 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({ return this.prisma.customer.update({
where: { id }, where: { id },
data: { archivedAt: null }, data: { archivedAt: null },
@@ -280,9 +288,46 @@ export class CustomersService {
private async ensureExists(id: string) { private async ensureExists(id: string) {
const found = await this.prisma.customer.findUnique({ const found = await this.prisma.customer.findUnique({
where: { id }, where: { id },
select: { id: true }, select: { id: true, archivedAt: true, folderNumber: true },
}); });
if (!found) throw new NotFoundException(`Customer ${id} not found`); 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<number> {
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. */ /** Top-line counts for a dashboard header. */
@@ -2,9 +2,11 @@ import {
IsBoolean, IsBoolean,
IsEmail, IsEmail,
IsEnum, IsEnum,
IsInt,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Min,
MinLength, MinLength,
} from "class-validator"; } from "class-validator";
import { Currency } from "@jorgecuadros/database"; import { Currency } from "@jorgecuadros/database";
@@ -12,6 +14,7 @@ import { Currency } from "@jorgecuadros/database";
/** Same editable fields as create, all optional. */ /** Same editable fields as create, all optional. */
export class UpdateCustomerDto { export class UpdateCustomerDto {
@IsOptional() @IsString() @MinLength(1) name?: string; @IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsInt() @Min(1) folderNumber?: number | null;
@IsOptional() @IsString() addressLine1?: string; @IsOptional() @IsString() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string; @IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() city?: string; @IsOptional() @IsString() city?: string;
+43 -42
View File
@@ -23,10 +23,7 @@ import {
notCashJournal, notCashJournal,
periodSourceTable, periodSourceTable,
} from "../billing/billing.service"; } from "../billing/billing.service";
import { import { UTILITIES_TABLE } from "../customers/numid.service";
UTILITIES_SYSTEM,
UTILITIES_TABLE,
} from "../customers/numid.service";
import type { PrismaService } from "../prisma/prisma.service"; import type { PrismaService } from "../prisma/prisma.service";
import { CONCEPT_BY_KIND } from "../statements/statements.service"; import { CONCEPT_BY_KIND } from "../statements/statements.service";
import { import {
@@ -50,8 +47,8 @@ function nameOf(c: { name: string; nameMissing: boolean }): string {
const isoDay = (d: Date | null | undefined) => const isoDay = (d: Date | null | undefined) =>
d ? d.toISOString().slice(0, 10) : "—"; d ? d.toISOString().slice(0, 10) : "—";
/** Every report leads with the legacy customer number (2026-10 meeting). */ /** Every report leads with the customer's shelf folder number (2026-10 meeting). */
const NUMID_COLUMN: ColumnDef = { key: "numid", label: "NUMid", type: "text" }; const FOLDER_COLUMN: ColumnDef = { key: "folder", label: "Carpeta", type: "text" };
/** Policy reports add the insurance file number beside it. */ /** Policy reports add the insurance file number beside it. */
const INSURANCE_NO_COLUMN: ColumnDef = { const INSURANCE_NO_COLUMN: ColumnDef = {
key: "insuranceNo", key: "insuranceNo",
@@ -60,38 +57,42 @@ const INSURANCE_NO_COLUMN: ColumnDef = {
}; };
/** /**
* Legacy customer numbers for a page of report rows. The office files, * Customer numbers for a page of report rows. The office files, captures and
* captures and talks in NUMids, not platform ids. * talks in folder numbers, not platform ids.
* *
* The utilities NUMid is the portal number, one per customer. insurance/DATGRAL * The insurance file number is the legacy insurance/DATGRAL id, a separate id
* is a separate id space under the same table name, so it is read on its own * space under the same table name as the utilities NUMid; 16 customers hold
* and never shown as a NUMid; 16 customers hold more than one. * more than one.
*/ */
async function legacyNumbers(prisma: PrismaService, customerIds: string[]) { async function legacyNumbers(prisma: PrismaService, customerIds: string[]) {
const ids = [...new Set(customerIds)]; const ids = [...new Set(customerIds)];
const refs = ids.length const [customers, refs] = ids.length
? await prisma.customerLegacyRef.findMany({ ? await Promise.all([
prisma.customer.findMany({
where: { id: { in: ids } },
select: { id: true, folderNumber: true },
}),
prisma.customerLegacyRef.findMany({
where: { where: {
customerId: { in: ids }, customerId: { in: ids },
sourceTable: UTILITIES_TABLE, sourceTable: UTILITIES_TABLE,
sourceSystem: { in: [UTILITIES_SYSTEM, "insurance"] }, sourceSystem: "insurance",
}, },
select: { customerId: true, sourceSystem: true, legacyId: true }, select: { customerId: true, legacyId: true },
}) }),
: []; ])
const byCustomer = new Map<string, { numid?: string; insurance: string[] }>(); : [[], []];
const folders = new Map(customers.map((c) => [c.id, c.folderNumber]));
const insurance = new Map<string, string[]>();
for (const r of refs) { for (const r of refs) {
const e = byCustomer.get(r.customerId) ?? { insurance: [] }; insurance.set(r.customerId, [...(insurance.get(r.customerId) ?? []), r.legacyId]);
if (r.sourceSystem === UTILITIES_SYSTEM) e.numid = r.legacyId;
else e.insurance.push(r.legacyId);
byCustomer.set(r.customerId, e);
} }
return { return {
numid: (id: string) => byCustomer.get(id)?.numid ?? "—", folder: (id: string) => String(folders.get(id) ?? "—"),
insurance: (id: string) => insurance: (id: string) =>
byCustomer insurance
.get(id) .get(id)
?.insurance.sort((a, b) => Number(a) - Number(b)) ?.sort((a, b) => Number(a) - Number(b))
.join(", ") || "—", .join(", ") || "—",
}; };
} }
@@ -182,7 +183,7 @@ const listadoEnRojo: ReportDef = {
}, },
], ],
columns: [ columns: [
NUMID_COLUMN, FOLDER_COLUMN,
{ key: "name", label: "Cliente", type: "text" }, { key: "name", label: "Cliente", type: "text" },
{ key: "city", label: "Ciudad", type: "text" }, { key: "city", label: "Ciudad", type: "text" },
{ key: "lastMovement", label: "Último movimiento", type: "date" }, { key: "lastMovement", label: "Último movimiento", type: "date" },
@@ -258,7 +259,7 @@ const listadoEnRojo: ReportDef = {
const b = r.balance ?? new Prisma.Decimal(0); const b = r.balance ?? new Prisma.Decimal(0);
totalBalance = totalBalance.plus(b); totalBalance = totalBalance.plus(b);
return { return {
numid: nums.numid(r.id), folder: nums.folder(r.id),
name: nameOf(r), name: nameOf(r),
city: r.city ?? "—", city: r.city ?? "—",
lastMovement: isoDay(r.lastMovement), lastMovement: isoDay(r.lastMovement),
@@ -348,7 +349,7 @@ const pagosNoEfectuados: ReportDef = {
}, },
], ],
columns: [ columns: [
NUMID_COLUMN, FOLDER_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "accountNumber", label: "Cuenta", type: "text" }, { key: "accountNumber", label: "Cuenta", type: "text" },
@@ -418,7 +419,7 @@ const pagosNoEfectuados: ReportDef = {
const out = rows.map((r) => { const out = rows.map((r) => {
if (Number(r.unfunded)) unfunded++; if (Number(r.unfunded)) unfunded++;
return { return {
numid: nums.numid(r.customerId), folder: nums.folder(r.customerId),
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }), customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
propertyAddress: r.addresses ?? "—", propertyAddress: r.addresses ?? "—",
accountNumber: r.accounts ?? "—", accountNumber: r.accounts ?? "—",
@@ -481,7 +482,7 @@ const faltantes: ReportDef = {
}, },
], ],
columns: [ columns: [
NUMID_COLUMN, FOLDER_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "missing", label: "Faltante", 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 nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
const out = rows.map((r) => ({ const out = rows.map((r) => ({
numid: nums.numid(r.customerId), folder: nums.folder(r.customerId),
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }), customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
propertyAddress: r.propertyAddress ?? "—", propertyAddress: r.propertyAddress ?? "—",
missing: r.missing, missing: r.missing,
@@ -571,7 +572,7 @@ const reporteDeEfectivo: ReportDef = {
], ],
columns: [ columns: [
{ key: "date", label: "Fecha", type: "date" }, { key: "date", label: "Fecha", type: "date" },
NUMID_COLUMN, FOLDER_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "concept", label: "Concepto", type: "text" }, { key: "concept", label: "Concepto", type: "text" },
{ key: "source", label: "Origen", type: "text" }, { key: "source", label: "Origen", type: "text" },
@@ -630,7 +631,7 @@ const reporteDeEfectivo: ReportDef = {
total = total.plus(r.amount); total = total.plus(r.amount);
return { return {
date: r.transactionDate.toISOString().slice(0, 10), date: r.transactionDate.toISOString().slice(0, 10),
numid: nums.numid(r.customer.id), folder: nums.folder(r.customer.id),
customerName: nameOf(r.customer), customerName: nameOf(r.customer),
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—", concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
source: r.legacySourceTable ?? "—", source: r.legacySourceTable ?? "—",
@@ -695,7 +696,7 @@ const vigente: ReportDef = {
], ],
columns: [ columns: [
{ key: "policyNumber", label: "Póliza", type: "text" }, { key: "policyNumber", label: "Póliza", type: "text" },
NUMID_COLUMN, FOLDER_COLUMN,
INSURANCE_NO_COLUMN, INSURANCE_NO_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "provider", label: "Aseguradora", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" },
@@ -743,7 +744,7 @@ const vigente: ReportDef = {
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
return { return {
policyNumber: r.policyNumber, policyNumber: r.policyNumber,
numid: nums.numid(r.customer.id), folder: nums.folder(r.customer.id),
insuranceNo: nums.insurance(r.customer.id), insuranceNo: nums.insurance(r.customer.id),
customerName: nameOf(r.customer), customerName: nameOf(r.customer),
provider: r.insuranceProvider?.name ?? "—", provider: r.insuranceProvider?.name ?? "—",
@@ -848,7 +849,7 @@ const avisoRenovacion: ReportDef = {
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement". // ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
columns: [ columns: [
{ key: "policyNumber", label: "Póliza", type: "text" }, { key: "policyNumber", label: "Póliza", type: "text" },
NUMID_COLUMN, FOLDER_COLUMN,
INSURANCE_NO_COLUMN, INSURANCE_NO_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "provider", label: "Aseguradora", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" },
@@ -888,7 +889,7 @@ const avisoRenovacion: ReportDef = {
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
const letter = { const letter = {
...toRenewalLetterRow(r, generation), ...toRenewalLetterRow(r, generation),
numid: nums.numid(r.customer.id), folder: nums.folder(r.customer.id),
insuranceNo: nums.insurance(r.customer.id), insuranceNo: nums.insurance(r.customer.id),
}; };
if (letter.sentAt) sentCount++; if (letter.sentAt) sentCount++;
@@ -968,7 +969,7 @@ const edoCuentaDatos: ReportDef = {
}, },
}); });
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" }; 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 // The source-table exclusion, the balance floor and the year scope below
// are BillingService.statement's, because this report and // are BillingService.statement's, because this report and
@@ -1133,7 +1134,7 @@ const edoCuentaDatos: ReportDef = {
{ {
__kind: "header", __kind: "header",
name: nameOf(customer), name: nameOf(customer),
numid, folder,
address: customer.addressLine1 ?? "", address: customer.addressLine1 ?? "",
city: [customer.city, customer.state].filter(Boolean).join(", "), city: [customer.city, customer.state].filter(Boolean).join(", "),
phone: customer.phone ?? "", phone: customer.phone ?? "",
@@ -1151,7 +1152,7 @@ const edoCuentaDatos: ReportDef = {
...carried, ...carried,
...movements, ...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: [ columns: [
NUMID_COLUMN, FOLDER_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" }, { key: "customerName", label: "Cliente", type: "text" },
{ key: "reference", label: "Referencia", type: "text" }, { key: "reference", label: "Referencia", type: "text" },
{ key: "period", label: "Periodo", 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)); const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
return { return {
rows: rows.map((r) => ({ rows: rows.map((r) => ({
numid: nums.numid(r.customer.id), folder: nums.folder(r.customer.id),
customerName: nameOf(r.customer), customerName: nameOf(r.customer),
reference: r.reference ?? "—", reference: r.reference ?? "—",
period: r.period ?? "—", period: r.period ?? "—",
+1
View File
@@ -256,6 +256,7 @@ function Hero({
.join(" · "); .join(" · ");
const facts: { label: string; value: string }[] = [ const facts: { label: string; value: string }[] = [
{ label: "Carpeta", value: data.folderNumber != null ? String(data.folderNumber) : "—" },
{ label: "Cliente desde", value: formatDate(data.customerSince) }, { label: "Cliente desde", value: formatDate(data.customerSince) },
{ {
label: "Cuota", label: "Cuota",
+3 -1
View File
@@ -125,7 +125,7 @@ function ClientesBrowser() {
type="search" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} 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" aria-label="Buscar clientes"
/> />
</div> </div>
@@ -261,6 +261,8 @@ function CustomerRow({ c }: { c: CustomerListItem }) {
)} )}
</div> </div>
<div className="cust-sub"> <div className="cust-sub">
{c.folderNumber != null && <span>Carpeta {c.folderNumber}</span>}
{c.folderNumber != null && (location || contact) && <span className="sep">·</span>}
{location && <span>{location}</span>} {location && <span>{location}</span>}
{location && contact && <span className="sep">·</span>} {location && contact && <span className="sep">·</span>}
{contact && <span>{contact}</span>} {contact && <span>{contact}</span>}
+21 -2
View File
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { CustomerDetail, CustomerInput, Currency } from "@/lib/types"; 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. */ /** ISO date (yyyy-mm-dd) for a date input, from an API date string. */
function toDateInput(v: string | null | undefined): string { function toDateInput(v: string | null | undefined): string {
@@ -20,6 +20,7 @@ function numOrUndef(v: string | number | null | undefined): number | undefined {
type Values = { type Values = {
name: string; name: string;
folderNumber: string;
addressLine1: string; addressLine1: string;
addressLine2: string; addressLine2: string;
city: string; city: string;
@@ -45,6 +46,7 @@ type Values = {
function initial(c?: CustomerDetail): Values { function initial(c?: CustomerDetail): Values {
return { return {
name: c?.name ?? "", name: c?.name ?? "",
folderNumber: c?.folderNumber != null ? String(c.folderNumber) : "",
addressLine1: c?.addressLine1 ?? "", addressLine1: c?.addressLine1 ?? "",
addressLine2: c?.addressLine2 ?? "", addressLine2: c?.addressLine2 ?? "",
city: c?.city ?? "", city: c?.city ?? "",
@@ -85,6 +87,17 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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<K extends keyof Values>(key: K, val: Values[K]) { function set<K extends keyof Values>(key: K, val: Values[K]) {
setV((prev) => ({ ...prev, [key]: val })); setV((prev) => ({ ...prev, [key]: val }));
} }
@@ -95,6 +108,8 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
setError(null); setError(null);
const payload: CustomerInput = { const payload: CustomerInput = {
name: v.name.trim(), 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), addressLine1: s(v.addressLine1),
addressLine2: s(v.addressLine2), addressLine2: s(v.addressLine2),
city: s(v.city), city: s(v.city),
@@ -138,6 +153,10 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
<input className="input" required value={v.name} <input className="input" required value={v.name}
onChange={(e) => set("name", e.target.value)} /> onChange={(e) => set("name", e.target.value)} />
</Field> </Field>
<Field label="Carpeta">
<input className="input" type="number" min={1} step={1} value={v.folderNumber}
onChange={(e) => set("folderNumber", e.target.value)} />
</Field>
<Field label="Correo"> <Field label="Correo">
<input className="input" type="email" value={v.email} <input className="input" type="email" value={v.email}
onChange={(e) => set("email", e.target.value)} /> onChange={(e) => set("email", e.target.value)} />
+2 -2
View File
@@ -390,8 +390,8 @@ function StatementLayout({ result }: { result: ReportRunResult }) {
<div className="statement"> <div className="statement">
<header className="statement-head"> <header className="statement-head">
<h2 className="statement-name">{String(header.name ?? "—")}</h2> <h2 className="statement-name">{String(header.name ?? "—")}</h2>
{header.numid && header.numid !== "—" ? ( {header.folder && header.folder !== "—" ? (
<p className="muted">NUMid {String(header.numid)}</p> <p className="muted">Carpeta {String(header.folder)}</p>
) : null} ) : null}
{Boolean(header.address) && ( {Boolean(header.address) && (
<p className="muted">{String(header.address)}</p> <p className="muted">{String(header.address)}</p>
+5
View File
@@ -215,6 +215,11 @@ export function listCustomers(
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`); return apiFetch<CustomerListResponse>(`/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<CustomerDetail> { export function getCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`); return apiFetch<CustomerDetail>(`/customers/${id}`);
} }
+4
View File
@@ -220,6 +220,7 @@ export interface CustomerListItem {
name: string; name: string;
/** Legacy table the name was recovered from; null when it came from DATGRAL. */ /** Legacy table the name was recovered from; null when it came from DATGRAL. */
nameSource: string | null; nameSource: string | null;
folderNumber: number | null;
city: string | null; city: string | null;
state: string | null; state: string | null;
email: string | null; email: string | null;
@@ -1113,6 +1114,7 @@ export interface CustomerDetail {
id: string; id: string;
name: string; name: string;
nameSource: string | null; nameSource: string | null;
folderNumber: number | null;
addressLine1: string | null; addressLine1: string | null;
addressLine2: string | null; addressLine2: string | null;
city: string | null; city: string | null;
@@ -1146,6 +1148,8 @@ export interface CustomerDetail {
/** Editable customer fields — shared by the create/edit form and the API. */ /** Editable customer fields — shared by the create/edit form and the API. */
export interface CustomerInput { export interface CustomerInput {
name: string; name: string;
/** null clears it. */
folderNumber?: number | null;
addressLine1?: string; addressLine1?: string;
addressLine2?: string; addressLine2?: string;
city?: string; city?: string;
+17 -3
View File
@@ -166,12 +166,17 @@ def resolve_name(raw, nid, index) -> tuple[str, str | None]:
# ------------------------------- record builders ---------------------------- # # ------------------------------- record builders ---------------------------- #
def customer_from_utilities(row, name_index) -> dict: 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( return dict(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
name=name, name=name,
nameSource=name_source, nameSource=name_source,
nameMissing=int(name == NO_NAME), 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"]), addressLine1=s(row["direccion"]),
addressLine2=s(row["colonia"]), addressLine2=s(row["colonia"]),
city=s(row["ciudad"]), city=s(row["ciudad"]),
@@ -205,6 +210,7 @@ def customer_from_insurance(row, name_index) -> dict:
name=name, name=name,
nameSource=name_source, nameSource=name_source,
nameMissing=int(name == NO_NAME), nameMissing=int(name == NO_NAME),
folderNumber=None, # insurance ids are a separate space, not a shelf folder
addressLine1=s(row["direccion_1"]), addressLine1=s(row["direccion_1"]),
addressLine2=s(row["direccion_2"]), addressLine2=s(row["direccion_2"]),
city=s(row["ciudad"]), city=s(row["ciudad"]),
@@ -228,7 +234,7 @@ def customer_from_insurance(row, name_index) -> dict:
_CUST_COLS = [ _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", "country", "phone", "mobile", "fax", "email", "notes", "identificationType",
"identificationNumber", "identificationExpiration", "customerSince", "identificationNumber", "identificationExpiration", "customerSince",
"status", "feeAmount", "minimumBalance", "updatedAt", "status", "feeAmount", "minimumBalance", "updatedAt",
@@ -322,7 +328,7 @@ def main() -> None:
remap[rec["id"]] = stable or rec["id"] remap[rec["id"]] = stable or rec["id"]
for rec in customers: for rec in customers:
rec["id"] = remap[rec["id"]] 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: 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)", 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])) (ref[0], remap[ref[1]], ref[2], ref[3], ref[4]))
@@ -374,6 +380,12 @@ def main() -> None:
recovered = cur.fetchall() recovered = cur.fetchall()
cur.execute("SELECT COUNT(*) FROM customers WHERE name = %s", (NO_NAME,)) cur.execute("SELECT COUNT(*) FROM customers WHERE name = %s", (NO_NAME,))
still_unnamed = cur.fetchone()[0] 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("=== Customer load complete ===")
print(f" utilities DATGRAL rows : {len(util)}") print(f" utilities DATGRAL rows : {len(util)}")
@@ -390,6 +402,8 @@ def main() -> None:
print(f" from {src:16} : {n}") print(f" from {src:16} : {n}")
print(f" of which via the linked insurance record : {from_ins_side}") print(f" of which via the linked insurance record : {from_ins_side}")
print(f" still {NO_NAME} : {still_unnamed}") 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: if not sync_mode:
# Full-load invariants only: sync upserts into an already-loaded (and # Full-load invariants only: sync upserts into an already-loaded (and
# pruned) table, so these exact counts don't hold. # pruned) table, so these exact counts don't hold.
@@ -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;
+8
View File
@@ -89,6 +89,13 @@ model Customer {
// can sort nameless records last — ordering by `name` alone puts them first, // can sort nameless records last — ordering by `name` alone puts them first,
// since "(" sorts before every letter. // since "(" sorts before every letter.
nameMissing Boolean @default(false) 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? addressLine1 String?
addressLine2 String? addressLine2 String?
city String? city String?
@@ -128,6 +135,7 @@ model Customer {
emailNotificationLogs EmailNotificationLog[] emailNotificationLogs EmailNotificationLog[]
accountStatusHistory AccountStatusHistory[] accountStatusHistory AccountStatusHistory[]
@@index([folderNumber])
@@map("customers") @@map("customers")
} }