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

+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 { 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<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. */