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
+27 -1
View File
@@ -83,7 +83,7 @@ guess.
| 1.3 | Carrier API **direction**: outbound quote/issue (ANA supports today) or inbound portfolio sync (no evidence either carrier offers it) | whether §4 is buildable at all | INSURANCE §4 |
| 1.4 | CFE amount: the rounded barcode figure (`$268`, what is paid at the window) or the exact breakdown total (`$268.88`) | the parser currently takes the barcode | STATEMENT_OCR / RECEIPT §2 |
| 1.5 | The Seguros USD bank's name, currency and details | multi-bank is built; that account does not exist yet | RECEIPT §3 |
| 1.6 | Recycling triggers — exact "1 year inactive" / "cancelled" definitions, and whether recycling ever means true data purge | §4 recycling | RECEIPT §4 |
| 1.6 | Whether recycling ever means a true data purge. The *triggers* are now settled and built (see §5 "NUMid allocation"); what is still open is whether a recycled id's old rows are ever deleted rather than left attached to the previous customer | nothing — the allocator ships without a purge | RECEIPT §4 |
| 1.7 | Notice body in Spanish or English | `Customer` carries no language preference | INSURANCE §1 |
| 1.8 | How to model `TRASPASOS PAYPAL` — a clearing account, not a customer, carrying 7.03M MXN over 309 movements and therefore topping the adeudo worklist | deliberately not special-cased in code | RESUME §6 |
| 1.9 | The 78 policyholders with no email — skip silently or produce a print worklist | recommendation is the worklist | INSURANCE §1 |
@@ -184,6 +184,32 @@ Each of these is a known, deliberate stopping point rather than a bug.
- Handwritten folder numbers are deliberately not an input to matching
(Tesseract read `405` as `205`).
**NUMid allocation**`POST /customers/:id/portal-access` assigns the portal
"Security Number", on a staff action rather than at create time, because an
insurance-only customer has no reason to hold a utilities id.
- **Recycling is built but switched off.** `numid.recycleEmpty` in `app_settings`
defaults to false, and that default is a safety property, not a preference:
every reusable id still exists in Access DATGRAL, and a `--sync` migration run
upserts refs with `ON DUPLICATE KEY UPDATE customerId`
(`transform_customers.py:327`), so an id recycled today is handed back to its
Access owner on the next sync and the customer given it loses portal access.
**Flip it on after utilities cuts over**, or for ids deleted at the source.
- **A full re-import destroys every natively allocated id.**
`transform_customers.py:246` truncates `customers` and `customer_legacy_refs`,
then rebuilds the pool from Access alone. Until that is fixed — either by
mandating `--sync` for all future utilities loads, or by teaching the transform
to preserve refs with no Access counterpart — a NUMid issued here survives only
until the next full load. This is a prerequisite for the cutover, not a
nice-to-have.
- **The empty-id rule exists twice**: enforced in `numid.service.ts`
(`EMPTY_NUMID_SQL`) and reported by `scripts/numid-audit.sql`. They agree today
(both return 1089, 1094, 1134, 1143 on dev); they are not mechanically kept in
step, so change them together.
- **No un-assign.** Nothing removes a NUMid once given, and nothing reports which
ids were recycled from whom beyond the `customer.portal-access` activity-log
entry.
**Bank** — the concept→ramo classifier is **won't-build**, not pending.
`concepto` is a payee name (0 of 22,354 match a category) and TABLA RAMODOS is
a property-management expense chart, not the business-line split it was assumed