feat(customers): allocate portal NUMids, with an audit for reusable ones
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Ability =
|
||||
| "customer:create"
|
||||
| "customer:update"
|
||||
| "customer:delete"
|
||||
| "customer:portal-access"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
|
||||
Reference in New Issue
Block a user