feat(customers): allocate portal NUMids, with an audit for reusable ones
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m59s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m13s

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:
2026-08-06 20:04:13 -07:00
co-authored by Claude Opus 5
parent 7981c715ce
commit 6a97242fc3
12 changed files with 880 additions and 3 deletions
+48 -1
View File
@@ -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
+14
View File
@@ -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
+1
View File
@@ -9,6 +9,7 @@ export type Ability =
| "customer:create"
| "customer:update"
| "customer:delete"
| "customer:portal-access"
| "policy:create"
| "policy:update"
| "policy:delete"