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
+261 -58

No files matched your search

+47 -46
View File
@@ -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<string, { numid?: string; insurance: string[] }>();
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<string, string[]>();
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 ?? "—",