Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6432efc8 | ||
|
|
022d1935ad | ||
|
|
5a277f4885 | ||
|
|
d645ba51d3 | ||
|
|
14c4d44acb | ||
|
|
45be0ad77d | ||
|
|
7be897ef2b | ||
|
|
cf40cd22ef | ||
|
|
3b02c6944f | ||
|
|
683fd37b08 | ||
|
|
14c6183aa2 | ||
|
|
5352d49ecf | ||
|
|
2169ffa78d | ||
|
|
17d83291c3 | ||
|
|
6a97242fc3 |
@@ -17,6 +17,12 @@
|
||||
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
|
||||
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
|
||||
# the rule, the backup is the emergency lever.
|
||||
# Done HERE so the schema moves while the OLD code is
|
||||
# still serving. The api container ALSO migrates at
|
||||
# start (docker/api-entrypoint.sh); `migrate deploy`
|
||||
# is idempotent, so the second run is a no-op and the
|
||||
# container is what covers a restart that never goes
|
||||
# through this workflow at all.
|
||||
# 4. app (api + web) the new images.
|
||||
# 5. verify ask the running API what it actually is.
|
||||
#
|
||||
@@ -55,10 +61,10 @@
|
||||
# uses until somebody saves them there
|
||||
# These are NOT galactus-specific (no _GALACTUS suffix) — one SES identity
|
||||
# serves every deployment.
|
||||
# - The runner (which lives on cubex) must be able to reach BOTH
|
||||
# galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy).
|
||||
# If it cannot reach 3306, run the migration by hand from a host that can
|
||||
# and dispatch with skip_migrate=true.
|
||||
# - The runner (which lives on cubex) must be able to reach galactus:9443
|
||||
# (Portainer). It should also reach galactus:3306 for step 3, but that is
|
||||
# no longer load-bearing: dispatch with skip_migrate=true and the api
|
||||
# container applies the migrations itself at start.
|
||||
# - ONE-TIME, on a database that predates migration history (i.e. one built
|
||||
# with `prisma db push`): baseline it before the first run, or step 3 fails
|
||||
# with P3005 "database schema is not empty":
|
||||
@@ -88,7 +94,7 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
skip_migrate:
|
||||
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
||||
description: "Skip the runner-side migrate step (safe: the api container migrates at start)"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
@@ -237,6 +243,10 @@ jobs:
|
||||
run: node deploy/scripts/pre-migrate-backup.mjs
|
||||
|
||||
# --- schema, forward-only ---------------------------------------------
|
||||
# Belt to the container's braces: this runs while the OLD code is still
|
||||
# serving, which is the order expand/contract is designed around. The
|
||||
# api container repeats it at start for the paths this step cannot
|
||||
# reach (skip_migrate, a host reboot, a stack re-applied by hand).
|
||||
- name: Apply database migrations
|
||||
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
||||
env:
|
||||
@@ -281,13 +291,20 @@ jobs:
|
||||
standalone: true
|
||||
pull: true
|
||||
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||
# NOTE: the block below is parsed as JSON — no comments inside it.
|
||||
#
|
||||
# API_ORIGIN is deliberately absent. The browser derives the API origin
|
||||
# from the page it loaded (apps/web/src/lib/api.ts), so the deployment
|
||||
# survives the box moving between the tailnet, the office LAN and a
|
||||
# demo domain. Setting it here would pin it again and re-break an https
|
||||
# front door with mixed active content. APP_API_ORIGIN_GALACTUS lives
|
||||
# on only as the URL the verify step probes.
|
||||
env_data: |
|
||||
{
|
||||
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||
"API_PORT": "3001",
|
||||
"WEB_PORT": "3000",
|
||||
"S3_BUCKET": "jorgecuadros-documents",
|
||||
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN_GALACTUS }}",
|
||||
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
|
||||
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
|
||||
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
|
||||
@@ -322,6 +339,12 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
apk add --no-cache curl >/dev/null
|
||||
# These secrets are CORS origin LISTS as far as the app is concerned
|
||||
# (WEB_ORIGIN is comma-separated so one deployment can be reached by
|
||||
# LAN IP, tailnet name and demo domain at once). A list is not a URL,
|
||||
# so probe the FIRST entry — keep the runner-reachable origin first.
|
||||
API_ORIGIN=${API_ORIGIN%%,*}
|
||||
WEB_ORIGIN=${WEB_ORIGIN%%,*}
|
||||
fetch_version() {
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# git tag v1.2.3 into image tag 1.2.3. Tag v1.2.3, dispatch 1.2.3.
|
||||
#
|
||||
# Order: db+minio (full only) -> pre-migrate backup -> prisma migrate deploy ->
|
||||
# (the api container also migrates at start; see docker/api-entrypoint.sh)
|
||||
# app -> verify the API reports the version you asked for. Rollback = dispatch
|
||||
# an older tag; that rolls back CODE only, never the schema, which is why every
|
||||
# schema change must be expand/contract. See docs/DEPLOY_AND_MIGRATIONS.md.
|
||||
@@ -47,9 +48,11 @@
|
||||
# # Database stack (full only)
|
||||
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
||||
# MYSQL_ROOT_PASSWORD mysql root password
|
||||
# - the runner must reach BOTH Portainer (9443) and MySQL (3306) — the
|
||||
# migration step connects to the database directly. If it cannot reach 3306,
|
||||
# migrate by hand and dispatch with skip_migrate=true.
|
||||
# - the runner must reach Portainer (9443). It should also reach MySQL (3306)
|
||||
# for the migrate step, but that is no longer load-bearing: dispatch with
|
||||
# skip_migrate=true and the api container applies the migrations itself at
|
||||
# start (docker/api-entrypoint.sh). `migrate deploy` is idempotent, so the
|
||||
# two never conflict.
|
||||
# - ONE-TIME on a database built with `prisma db push` (i.e. every database
|
||||
# that exists today): baseline it before the first run, or the migrate step
|
||||
# fails with P3005 "database schema is not empty":
|
||||
@@ -79,7 +82,7 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
skip_migrate:
|
||||
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
||||
description: "Skip the runner-side migrate step (safe: the api container migrates at start)"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
@@ -253,13 +256,19 @@ jobs:
|
||||
type: file
|
||||
pull: true
|
||||
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||
# NOTE: the block below is parsed as JSON — no comments inside it.
|
||||
#
|
||||
# API_ORIGIN is deliberately absent. The browser derives the API origin
|
||||
# from the page it loaded (apps/web/src/lib/api.ts), so the deployment
|
||||
# survives the host moving. Setting it here would pin it again and
|
||||
# re-break an https front door with mixed active content. APP_API_ORIGIN
|
||||
# lives on only as the URL the verify step probes.
|
||||
env_data: |
|
||||
{
|
||||
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||
"API_PORT": "3001",
|
||||
"WEB_PORT": "3000",
|
||||
"S3_BUCKET": "jorgecuadros-documents",
|
||||
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN }}",
|
||||
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN }}",
|
||||
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
||||
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
||||
@@ -288,6 +297,12 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
apk add --no-cache curl >/dev/null
|
||||
# These secrets are CORS origin LISTS as far as the app is concerned
|
||||
# (WEB_ORIGIN is comma-separated so one deployment can be reached under
|
||||
# several origins at once). A list is not a URL, so probe the FIRST
|
||||
# entry — keep the runner-reachable origin first.
|
||||
API_ORIGIN=${API_ORIGIN%%,*}
|
||||
WEB_ORIGIN=${WEB_ORIGIN%%,*}
|
||||
fetch_version() {
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
||||
|
||||
@@ -96,7 +96,14 @@ NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
||||
```
|
||||
|
||||
The API loads `DATABASE_URL`, `SESSION_SECRET`, `WEB_ORIGIN`, and optional
|
||||
`PORT` (default `3001`). The web app only needs `NEXT_PUBLIC_API_ORIGIN`.
|
||||
`PORT` (default `3001`). `WEB_ORIGIN` is comma-separated — list every origin the
|
||||
app is reached under, or credentialed fetches from the missing ones fail CORS.
|
||||
|
||||
The web app needs no API URL of its own: the browser derives it from the page it
|
||||
loaded (same host on port `3001` over plain HTTP, or the same-origin `/api` path
|
||||
behind a TLS proxy). Set `NEXT_PUBLIC_API_ORIGIN` (dev) or `API_ORIGIN` (deploy,
|
||||
read at request time) only to override that — for instance when running the API
|
||||
on a non-default port.
|
||||
|
||||
### 3. Start MySQL
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/api",
|
||||
"version": "1.0.15",
|
||||
"version": "1.0.19",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -21,6 +21,7 @@ export type Ability =
|
||||
| "customer:create"
|
||||
| "customer:update"
|
||||
| "customer:delete"
|
||||
| "customer:portal-access"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
@@ -48,6 +49,12 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
"customer:create": "STAFF",
|
||||
"customer:update": "STAFF",
|
||||
"customer:delete": "ADMIN",
|
||||
// Assigning a portal NUMid is granting someone the ability to log in to
|
||||
// my.jorgecuadros.com and read an account, so it sits above customer:update:
|
||||
// editing a phone number is the day job, handing out portal identity is not.
|
||||
// It is also close to irreversible in practice — the id is what the customer
|
||||
// then types at every login.
|
||||
"customer:portal-access": "MANAGER",
|
||||
"policy:create": "STAFF",
|
||||
"policy:update": "STAFF",
|
||||
"policy:delete": "MANAGER",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
|
||||
@@ -24,6 +25,7 @@ import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
export class CustomersController {
|
||||
constructor(
|
||||
private readonly customers: CustomersService,
|
||||
private readonly numids: NumidService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@@ -36,6 +38,13 @@ export class CustomersController {
|
||||
return this.customers.stats();
|
||||
}
|
||||
|
||||
/** Reusable portal ids, lowest first. Declared above `:id` so the literal
|
||||
* path is not swallowed by the wildcard route. */
|
||||
@Get("numid/candidates")
|
||||
async numidCandidates() {
|
||||
return { candidates: await this.numids.emptyCandidates() };
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@@ -95,4 +104,27 @@ export class CustomersController {
|
||||
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give this customer a portal NUMid so they can log in to
|
||||
* my.jorgecuadros.com. Idempotent — a customer who already has one gets it
|
||||
* back rather than a second identity.
|
||||
*/
|
||||
@Post(":id/portal-access")
|
||||
@RequireAbility("customer:portal-access")
|
||||
async portalAccess(@Param("id") id: string, @Req() req: Request) {
|
||||
const allocation = await this.numids.allocate(id);
|
||||
if (allocation.origin !== "existing") {
|
||||
// Logged with the origin and the previous holder: a recycled id is the one
|
||||
// case where reading this record later has to answer "whose number was
|
||||
// this before, and was it taken or minted".
|
||||
void this.audit.log(this.actingId(req), "customer.portal-access", {
|
||||
customerId: id,
|
||||
numid: allocation.numid,
|
||||
origin: allocation.origin,
|
||||
previousCustomerId: allocation.previousCustomerId,
|
||||
});
|
||||
}
|
||||
return allocation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SettingsModule } from "../settings/settings.module";
|
||||
import { CustomersController } from "./customers.controller";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
|
||||
@Module({
|
||||
imports: [SettingsModule],
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
providers: [CustomersService, NumidService],
|
||||
exports: [NumidService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { ConflictException, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { NumidService } from "./numid.service";
|
||||
|
||||
/**
|
||||
* What matters about the allocator is the two things it must never do: hand the
|
||||
* same id to two customers, and hand out a recycled id while Access can still
|
||||
* take it back. Both are tested here; the emptiness SQL itself is exercised
|
||||
* against real data by scripts/numid-audit.mjs.
|
||||
*/
|
||||
|
||||
interface Options {
|
||||
existingRef?: { legacyId: string } | null;
|
||||
archived?: boolean;
|
||||
missing?: boolean;
|
||||
recycle?: boolean;
|
||||
empty?: { numid: string; refId: string; customerId: string }[];
|
||||
max?: number | null;
|
||||
/** Make the first N create() calls fail the unique key, as a race would. */
|
||||
createConflicts?: number;
|
||||
/** Make updateMany report "nothing matched", as a lost recycle race would. */
|
||||
recycleMisses?: number;
|
||||
}
|
||||
|
||||
function build(opts: Options = {}) {
|
||||
const created: { legacyId: string }[] = [];
|
||||
let conflictsLeft = opts.createConflicts ?? 0;
|
||||
let missesLeft = opts.recycleMisses ?? 0;
|
||||
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn().mockResolvedValue(
|
||||
opts.missing ? null : { id: "cust-new", archivedAt: opts.archived ? new Date() : null },
|
||||
),
|
||||
},
|
||||
customerLegacyRef: {
|
||||
findFirst: jest.fn().mockResolvedValue(opts.existingRef ?? null),
|
||||
updateMany: jest.fn().mockImplementation(() => {
|
||||
if (missesLeft > 0) {
|
||||
missesLeft -= 1;
|
||||
return Promise.resolve({ count: 0 });
|
||||
}
|
||||
return Promise.resolve({ count: 1 });
|
||||
}),
|
||||
create: jest.fn().mockImplementation(({ data }: { data: { legacyId: string } }) => {
|
||||
if (conflictsLeft > 0) {
|
||||
conflictsLeft -= 1;
|
||||
return Promise.reject(
|
||||
new Prisma.PrismaClientKnownRequestError("dup", {
|
||||
code: "P2002",
|
||||
clientVersion: "5",
|
||||
}),
|
||||
);
|
||||
}
|
||||
created.push(data);
|
||||
return Promise.resolve(data);
|
||||
}),
|
||||
},
|
||||
// Two different raw queries share one mock: the MAX lookup returns a single
|
||||
// {max} row, everything else is the empty-candidate list.
|
||||
$queryRaw: jest.fn().mockImplementation((sql: { strings?: string[]; sql?: string }) => {
|
||||
const text = String((sql as unknown as { sql?: string }).sql ?? "");
|
||||
if (text.includes("MAX(")) return Promise.resolve([{ max: opts.max ?? null }]);
|
||||
return Promise.resolve(opts.empty ?? []);
|
||||
}),
|
||||
};
|
||||
|
||||
const settings = {
|
||||
numidRecycleEmpty: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ value: opts.recycle ?? false, source: "default" }),
|
||||
};
|
||||
|
||||
return {
|
||||
service: new NumidService(prisma as never, settings as never),
|
||||
prisma,
|
||||
created,
|
||||
};
|
||||
}
|
||||
|
||||
describe("NUMid allocation", () => {
|
||||
it("returns the id a customer already holds instead of minting a second one", async () => {
|
||||
// A double-clicked button must not fork the customer's portal identity.
|
||||
const { service, prisma } = build({ existingRef: { legacyId: "501" } });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||
numid: "501",
|
||||
origin: "existing",
|
||||
});
|
||||
expect(prisma.customerLegacyRef.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allocates one past the highest id in the pool", async () => {
|
||||
const { service, created } = build({ max: 1171 });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
expect(created[0]).toMatchObject({
|
||||
sourceSystem: "utilities",
|
||||
sourceTable: "DATGRAL",
|
||||
legacyId: "1172",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts at 1 when the pool is empty", async () => {
|
||||
const { service } = build({ max: null });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1" });
|
||||
});
|
||||
|
||||
it("does NOT recycle while the setting is off, even with candidates free", async () => {
|
||||
// The default has to be the safe one: every reusable id still exists in
|
||||
// Access, and a --sync run reassigns it back to its Access owner.
|
||||
const { service, prisma } = build({
|
||||
max: 1171,
|
||||
empty: [{ numid: "1089", refId: "ref-1089", customerId: "cust-old" }],
|
||||
});
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
expect(prisma.customerLegacyRef.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes the lowest empty id once recycling is switched on", async () => {
|
||||
const { service, prisma } = build({
|
||||
recycle: true,
|
||||
max: 1171,
|
||||
empty: [
|
||||
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||
numid: "1089",
|
||||
origin: "recycled",
|
||||
previousCustomerId: "cust-old",
|
||||
});
|
||||
// Guarded on the owner read a moment ago, so a ref that moved underneath us
|
||||
// matches nothing rather than being stolen.
|
||||
expect(prisma.customerLegacyRef.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "ref-1089", customerId: "cust-old" },
|
||||
data: { customerId: "cust-new" },
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a candidate that someone else took first", async () => {
|
||||
const { service } = build({
|
||||
recycle: true,
|
||||
max: 1171,
|
||||
recycleMisses: 1,
|
||||
empty: [
|
||||
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1094",
|
||||
origin: "recycled",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a new id when recycling is on but nothing is free", async () => {
|
||||
const { service } = build({ recycle: true, max: 1171, empty: [] });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries when two writers pick the same id", async () => {
|
||||
// The unique key on (sourceSystem, sourceTable, legacyId) is what decides
|
||||
// the winner; the loser must retry, never overwrite.
|
||||
const { service, prisma } = build({ max: 1171, createConflicts: 1 });
|
||||
|
||||
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||
numid: "1172",
|
||||
origin: "new",
|
||||
});
|
||||
expect(prisma.customerLegacyRef.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("gives up loudly rather than looping forever", async () => {
|
||||
const { service } = build({ max: 1171, createConflicts: 99 });
|
||||
|
||||
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses an archived customer", async () => {
|
||||
const { service } = build({ archived: true });
|
||||
|
||||
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses a customer that does not exist", async () => {
|
||||
const { service } = build({ missing: true });
|
||||
|
||||
await expect(service.allocate("nope")).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { SettingsService } from "../settings/settings.service";
|
||||
|
||||
/**
|
||||
* Allocation of the portal NUMid — the "Security Number" my.jorgecuadros.com
|
||||
* asks for at login.
|
||||
*
|
||||
* The NUMid is not a column on `Customer`. It is a `CustomerLegacyRef` row with
|
||||
* (sourceSystem='utilities', sourceTable='DATGRAL'), and `CustomersService.create`
|
||||
* deliberately writes none: a natively created customer has no legacy provenance.
|
||||
* The consequence is that every customer created in the staff UI is invisible to
|
||||
* the portal until this service gives them an id.
|
||||
*
|
||||
* WHY THIS IS NOT DONE AT CREATE TIME. Insurance is expected to move to the
|
||||
* platform before utilities, and an insurance-only customer has no reason to hold
|
||||
* a portal identity. Allocating on every create would spend utilities ids — and
|
||||
* the handful of reusable ones — on people who will never log in. So this is an
|
||||
* explicit staff action instead.
|
||||
*/
|
||||
|
||||
/** The pair that identifies a portal NUMid. */
|
||||
export const UTILITIES_SYSTEM = "utilities";
|
||||
export const UTILITIES_TABLE = "DATGRAL";
|
||||
|
||||
/**
|
||||
* insurance/DATGRAL is a SEPARATE id space that reuses the same sourceTable name
|
||||
* and runs past 4,000. It must never be read as a NUMid, and never allocated
|
||||
* from: the portal cannot resolve those ids. Every query here filters on BOTH
|
||||
* columns for that reason, never on sourceTable alone. A customer can also hold
|
||||
* more than one insurance ref — 16 of them do, where several insurance rows
|
||||
* folded into one customer — so those are tested with EXISTS rather than joined.
|
||||
*/
|
||||
const POOL = {
|
||||
sourceSystem: UTILITIES_SYSTEM,
|
||||
sourceTable: UTILITIES_TABLE,
|
||||
} as const;
|
||||
|
||||
export type AllocationOrigin = "existing" | "new" | "recycled";
|
||||
|
||||
export interface Allocation {
|
||||
numid: string;
|
||||
origin: AllocationOrigin;
|
||||
/** Set only on a recycle — the customer the id was taken from. */
|
||||
previousCustomerId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NUMids that were created and never used, safe for an allocator to take.
|
||||
*
|
||||
* THE TWO OBVIOUS RULES BOTH FIND NOTHING, which is why this one looks the way
|
||||
* it does. "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 in the current year. That row has to be subtracted before any
|
||||
* activity test means anything, which is what `bf` does below.
|
||||
*
|
||||
* The balance-forward row is matched in two shapes on purpose.
|
||||
* transform_transactions.py:120 mints a type literally named 'BALANCE FORWARD';
|
||||
* databases loaded before that change carry the same rows with typeId NULL,
|
||||
* dated Jan 1, legacySourceTable='datos2'. Matching only the type name floors
|
||||
* nothing on such a database and turns the balance test into a raw lifetime sum
|
||||
* — the double-count that read the whole book as +20.6M MXN in credit before
|
||||
* d173c9e, and which here would mark live customers as empty.
|
||||
*
|
||||
* Services are tested as "any service" rather than "any ACTIVE service": a
|
||||
* deactivated water account is still a record of somebody having lived behind
|
||||
* this id.
|
||||
*
|
||||
* Kept in step with scripts/numid-audit.sql, which reports the same tier for a
|
||||
* human. That script is the reporting copy of this rule; change both together.
|
||||
*/
|
||||
const EMPTY_NUMID_SQL = Prisma.sql`
|
||||
WITH bf AS (
|
||||
SELECT t.id, t.customerId
|
||||
FROM transactions t
|
||||
LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.voidedAt IS NULL
|
||||
AND (
|
||||
tt.nameEn = 'BALANCE FORWARD'
|
||||
OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1
|
||||
AND t.legacySourceTable = 'datos2')
|
||||
)
|
||||
)
|
||||
SELECT r.legacyId AS numid, r.id AS refId, r.customerId AS customerId
|
||||
FROM customer_legacy_refs r
|
||||
JOIN customers c ON c.id = r.customerId
|
||||
WHERE r.sourceSystem = ${UTILITIES_SYSTEM} AND r.sourceTable = ${UTILITIES_TABLE}
|
||||
AND (c.email IS NULL OR c.email = '')
|
||||
AND NOT EXISTS (SELECT 1 FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||
AND t.id NOT IN (SELECT id FROM bf))
|
||||
AND NOT EXISTS (SELECT 1 FROM transactions t
|
||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.outstanding = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM property_services ps
|
||||
JOIN properties p ON p.id = ps.propertyId WHERE p.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM policies p WHERE p.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM vehicles v WHERE v.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM trust_accounts ta
|
||||
JOIN properties p ON p.id = ta.propertyId WHERE p.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM statement_documents s WHERE s.matchedCustomerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM policy_ocr_documents o WHERE o.matchedCustomerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM email_notification_log e WHERE e.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM email_log e WHERE e.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM account_status_history a WHERE a.customerId = c.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM customer_legacy_refs i
|
||||
WHERE i.customerId = c.id AND i.sourceSystem = 'insurance')
|
||||
ORDER BY CAST(r.legacyId AS UNSIGNED)`;
|
||||
|
||||
interface EmptyRow {
|
||||
numid: string;
|
||||
refId: string;
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NumidService {
|
||||
private readonly logger = new Logger(NumidService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
/** The customer's portal id, or null if they have none. */
|
||||
async current(customerId: string): Promise<string | null> {
|
||||
const ref = await this.prisma.customerLegacyRef.findFirst({
|
||||
where: { customerId, ...POOL },
|
||||
select: { legacyId: true },
|
||||
});
|
||||
return ref?.legacyId ?? null;
|
||||
}
|
||||
|
||||
/** Reusable ids, lowest first. Empty unless recycling is switched on. */
|
||||
async emptyCandidates(): Promise<string[]> {
|
||||
const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
|
||||
return rows.map((r) => r.numid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a customer a portal NUMid.
|
||||
*
|
||||
* Idempotent: a customer who already holds one gets it back rather than a
|
||||
* second id, so a double-clicked button cannot fork an identity.
|
||||
*/
|
||||
async allocate(customerId: string): Promise<Allocation> {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { id: true, archivedAt: true },
|
||||
});
|
||||
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
if (customer.archivedAt) {
|
||||
throw new ConflictException(
|
||||
"No se puede asignar un número de portal a un cliente archivado",
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.current(customerId);
|
||||
if (existing) return { numid: existing, origin: "existing" };
|
||||
|
||||
const recycle = await this.recycleEnabled();
|
||||
|
||||
// Two writers can pick the same id between the read and the write. The
|
||||
// unique key on (sourceSystem, sourceTable, legacyId) is what actually
|
||||
// decides the winner; the loser retries and takes the next id rather than
|
||||
// silently overwriting. Bounded so a genuinely wedged pool fails loudly.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
if (recycle) {
|
||||
const recycled = await this.tryRecycle(customerId);
|
||||
if (recycled) return recycled;
|
||||
}
|
||||
return await this.allocateNext(customerId);
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
this.logger.warn(
|
||||
`NUMid allocation for ${customerId} lost a race (attempt ${attempt + 1}), retrying`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new ConflictException(
|
||||
"No se pudo asignar un número de portal; intente de nuevo",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the recycle tier is live.
|
||||
*
|
||||
* Off by default, and that default is the safe one while Access is still the
|
||||
* utilities master. Every id in the pool ALSO 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 silently handed
|
||||
* back to its Access owner on the next sync, and the customer who was given it
|
||||
* loses their portal identity. Turn this on once utilities has cut over, or
|
||||
* for ids that have been deleted at the source.
|
||||
*/
|
||||
private async recycleEnabled(): Promise<boolean> {
|
||||
const { value } = await this.settings.numidRecycleEmpty();
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Re-point the lowest empty id at this customer. Null when none is free. */
|
||||
private async tryRecycle(customerId: string): Promise<Allocation | null> {
|
||||
const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
|
||||
for (const row of rows) {
|
||||
// Guarded by the owner we just read: if anything moved the ref in the
|
||||
// meantime the update matches nothing and we fall through to the next
|
||||
// candidate rather than stealing an id that is no longer empty.
|
||||
const moved = await this.prisma.customerLegacyRef.updateMany({
|
||||
where: { id: row.refId, customerId: row.customerId },
|
||||
data: { customerId },
|
||||
});
|
||||
if (moved.count === 1) {
|
||||
this.logger.log(
|
||||
`NUMid ${row.numid} recycled from ${row.customerId} to ${customerId}`,
|
||||
);
|
||||
return {
|
||||
numid: row.numid,
|
||||
origin: "recycled",
|
||||
previousCustomerId: row.customerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One past the highest id in the pool. */
|
||||
private async allocateNext(customerId: string): Promise<Allocation> {
|
||||
const [{ max }] = await this.prisma.$queryRaw<{ max: number | null }[]>(
|
||||
// MAX over a CAST, not over the string: legacyId is VARCHAR, so a plain
|
||||
// MAX returns '999' as the highest of 1,171 rows and the allocator hands
|
||||
// out an id that is already taken.
|
||||
Prisma.sql`SELECT MAX(CAST(legacyId AS UNSIGNED)) AS max
|
||||
FROM customer_legacy_refs
|
||||
WHERE sourceSystem = ${UTILITIES_SYSTEM} AND sourceTable = ${UTILITIES_TABLE}`,
|
||||
);
|
||||
const numid = String(Number(max ?? 0) + 1);
|
||||
await this.prisma.customerLegacyRef.create({
|
||||
data: { customerId, ...POOL, legacyId: numid },
|
||||
});
|
||||
return { numid, origin: "new" };
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
||||
);
|
||||
}
|
||||
+13
-1
@@ -60,7 +60,19 @@ async function bootstrap() {
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
app.enableCors({ credentials: true, origin: process.env.WEB_ORIGIN ?? "http://localhost:3000" });
|
||||
// The same deployment is reached under several origins — the office LAN IP,
|
||||
// the tailnet name, the demo domain — and the browser derives the API origin
|
||||
// from whichever one served the page (apps/web/src/lib/api.ts). So WEB_ORIGIN
|
||||
// is a comma-separated LIST, not a single value. A request whose Origin is
|
||||
// not listed gets no CORS headers and the credentialed fetch fails, so add an
|
||||
// entry when a new way of reaching the app is introduced. Same-origin setups
|
||||
// (web and API behind one proxy) never hit CORS at all.
|
||||
const webOrigins = (process.env.WEB_ORIGIN ?? "http://localhost:3000")
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
app.enableCors({ credentials: true, origin: webOrigins });
|
||||
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 3001;
|
||||
await app.listen(port);
|
||||
|
||||
@@ -104,6 +104,24 @@ export class OpsController {
|
||||
return this.replication.status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full row-by-row comparison of the customer-visible tables against the master.
|
||||
*
|
||||
* POST rather than GET despite reading nothing: it is a full scan of both
|
||||
* servers and must not be something a browser prefetch, a retry, or a refresh
|
||||
* can set off. Audited for the same reason — it is a deliberate, costly act,
|
||||
* and "who ran this while the site was slow" is a question worth answering.
|
||||
*/
|
||||
@Post("replication/verify")
|
||||
async verifyReplication(@Req() req: Request) {
|
||||
const result = await this.replication.verify();
|
||||
void this.audit.log(this.actingId(req), "ops.replication.verify", {
|
||||
identical: result.identical,
|
||||
elapsedMs: result.elapsedMs,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get("jobs")
|
||||
listJobs() {
|
||||
return this.ops.listJobs();
|
||||
@@ -117,11 +135,17 @@ export class OpsController {
|
||||
@Post("jobs")
|
||||
async startJob(@Body() dto: StartJobDto, @Req() req: Request) {
|
||||
const userId = this.actingId(req);
|
||||
const job = await this.ops.startJob(dto.kind, { file: dto.file }, userId);
|
||||
const job = await this.ops.startJob(
|
||||
dto.kind,
|
||||
{ file: dto.file, forceFull: dto.forceFull },
|
||||
userId,
|
||||
);
|
||||
void this.audit.log(userId, "ops.job.start", {
|
||||
jobId: job.id,
|
||||
kind: dto.kind,
|
||||
file: dto.file,
|
||||
// Recorded because this is the flag that authorised deleting native rows.
|
||||
forceFull: dto.forceFull,
|
||||
});
|
||||
return job;
|
||||
}
|
||||
|
||||
@@ -415,12 +415,19 @@ export class OpsService implements OnModuleInit {
|
||||
const out = shq(path.join(this.backupDir, file));
|
||||
const py = await this.pythonBin();
|
||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||
// run_all.py runs native_guard.py before it truncates anything and exits
|
||||
// without touching the database when the target holds rows that only
|
||||
// exist here — allocated portal NUMids, app-created customers, OCR
|
||||
// captures. --force-full is what the operator ticks to delete them
|
||||
// anyway; without it the job fails with the list.
|
||||
const force = params.forceFull === true;
|
||||
const cmd =
|
||||
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`${this.dumpCommand(flags, db, out)} && ` +
|
||||
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage` +
|
||||
(force ? " --force-full" : "");
|
||||
return { cmd, resolvedParams: { safetyBackup: file, forceFull: force } };
|
||||
}
|
||||
|
||||
throw new BadRequestException(`Operación no soportada: ${kind}`);
|
||||
|
||||
@@ -43,6 +43,58 @@ export interface ApplyProgress {
|
||||
percent: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the replica's executed history is from the master's, in transactions.
|
||||
*
|
||||
* This is the check `SHOW REPLICA STATUS` cannot give you, and it is stronger
|
||||
* than everything else on the card for one specific reason: every other field is
|
||||
* self-reported by the replica. `Seconds_Behind_Source` reads 0 both when there
|
||||
* is genuinely nothing to apply AND when the I/O thread is disconnected — with
|
||||
* no incoming event there is nothing to measure staleness against, so a dead
|
||||
* link reports as perfectly current. `GTID_SUBTRACT(master, replica)` asks the
|
||||
* master what it has done and the replica what it has applied, so a silent
|
||||
* disconnect shows up immediately as a growing number.
|
||||
*/
|
||||
export interface GtidDrift {
|
||||
/** Transactions the master executed that the replica has not. 0 = identical. */
|
||||
missingTransactions: number;
|
||||
/** The missing GTID set verbatim. Null when nothing is missing. */
|
||||
missingGtidSet: string | null;
|
||||
/**
|
||||
* Transactions in the replica's `gtid_executed` under its OWN server UUID —
|
||||
* writes that happened here and exist nowhere on the master.
|
||||
*
|
||||
* Reported, never alarmed on. A non-zero count is the expected residue of the
|
||||
* seed load: restoring a dump executes its statements locally, and they take
|
||||
* GTIDs from this server's UUID. They never propagate (`log_replica_updates`
|
||||
* is off and nothing sources from this node), so they are harmless — right up
|
||||
* until someone tries to promote this box, where they become a real divergence.
|
||||
*/
|
||||
localTransactions: number;
|
||||
}
|
||||
|
||||
/** One table's row count and content fingerprint, on one side of the link. */
|
||||
export interface TableFingerprint {
|
||||
table: string;
|
||||
masterRows: number;
|
||||
replicaRows: number;
|
||||
/** Order-independent checksum over every column of every row. */
|
||||
masterChecksum: string;
|
||||
replicaChecksum: string;
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
/** True only when every table matched on both count and checksum. */
|
||||
identical: boolean;
|
||||
tables: TableFingerprint[];
|
||||
/** Set instead of `tables` when the comparison could not be run at all. */
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
/** Wall-clock cost, because this is a full scan and the caller should see it. */
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ReplicationStatus {
|
||||
/** false when the replica is not configured for this environment at all. */
|
||||
configured: boolean;
|
||||
@@ -58,11 +110,31 @@ export interface ReplicationStatus {
|
||||
sourceHost: string | null;
|
||||
/** Relay-log apply progress. Null when the status output has no positions. */
|
||||
apply: ApplyProgress | null;
|
||||
/** GTID comparison against the master. Null when the master was unreachable. */
|
||||
drift: GtidDrift | null;
|
||||
/** Human-readable reason when healthy is false. */
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tables `my.jorgecuadros.com` reads through the `web_reader` grant.
|
||||
*
|
||||
* This list is the verification surface, not the replication surface — the
|
||||
* replica carries the whole schema. These are the eight whose divergence would
|
||||
* actually be visible to a customer, so they are the ones worth a full scan.
|
||||
*/
|
||||
export const REPLICATED_TABLES = [
|
||||
"transactions",
|
||||
"customers",
|
||||
"customer_legacy_refs",
|
||||
"type_transactions",
|
||||
"exchange_rates",
|
||||
"properties",
|
||||
"property_services",
|
||||
"trust_accounts",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Reports whether the my.jorgecuadros.com read replica is still replicating.
|
||||
*
|
||||
@@ -99,6 +171,7 @@ export class ReplicationService {
|
||||
lastSqlError: null,
|
||||
sourceHost: null,
|
||||
apply: null,
|
||||
drift: null,
|
||||
problem: null,
|
||||
checkedAt: now,
|
||||
};
|
||||
@@ -109,33 +182,7 @@ export class ReplicationService {
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
// --ssl is required: the replica sets require_secure_transport=ON.
|
||||
//
|
||||
// --ssl-verify-server-cert=0 is deliberate and is NOT the same trade-off
|
||||
// the website makes. This hop never leaves Tailscale — the replica is
|
||||
// reached on its CGNAT tailnet address and its firewall admits only this
|
||||
// host — so WireGuard already authenticates the peer. The DreamHost leg
|
||||
// crosses the public internet and therefore pins the CA instead. The
|
||||
// client here is MariaDB's, which rejects our self-signed CA outright
|
||||
// unless it is handed the CA file, which would mean shipping a cert into
|
||||
// this image for a link that is already authenticated.
|
||||
const { stdout } = await exec(
|
||||
"mysql",
|
||||
[
|
||||
`--host=${host}`,
|
||||
`--user=${user}`,
|
||||
"--ssl",
|
||||
"--ssl-verify-server-cert=0",
|
||||
"--connect-timeout=5",
|
||||
"-e",
|
||||
"SHOW REPLICA STATUS\\G",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, MYSQL_PWD: password },
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
raw = stdout;
|
||||
raw = await this.onReplica("SHOW REPLICA STATUS\\G");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.warn(`no se pudo consultar la réplica: ${msg}`);
|
||||
@@ -189,10 +236,341 @@ export class ReplicationService {
|
||||
// alarming on it would cry wolf. It is here to answer "is it moving?"
|
||||
// when the lag counter is stuck.
|
||||
apply: applyProgress(raw),
|
||||
// Also reported rather than alarmed on, for the same reason: a busy master
|
||||
// is always a few transactions ahead for the instant they are in flight.
|
||||
// Null rather than zero when the master could not be reached — "unknown"
|
||||
// and "identical" must not render the same.
|
||||
drift: await this.gtidDrift(),
|
||||
problem,
|
||||
checkedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare executed history between master and replica.
|
||||
*
|
||||
* Two round trips: ask the master what it has executed, then ask the replica
|
||||
* to subtract its own history from that. The subtraction runs on the replica
|
||||
* rather than in TypeScript because `GTID_SUBTRACT` already implements the
|
||||
* interval algebra correctly, and reimplementing set subtraction over binlog
|
||||
* ranges is exactly the kind of thing that looks right and is wrong at the
|
||||
* boundaries.
|
||||
*
|
||||
* @returns null on any failure — a broken drift check must never be mistaken
|
||||
* for a healthy zero.
|
||||
*/
|
||||
private async gtidDrift(): Promise<GtidDrift | null> {
|
||||
try {
|
||||
const masterGtid = (await this.onMaster("SELECT @@gtid_executed")).trim();
|
||||
|
||||
// GTID sets are UUIDs, digits, colons, commas, hyphens, whitespace and
|
||||
// (since 8.4) alphanumeric tags. Nothing else is legal, so rejecting
|
||||
// anything outside that alphabet is a whitelist, not a blacklist: with no
|
||||
// quote and no backslash able to survive it, the value cannot escape the
|
||||
// string literal it is interpolated into below.
|
||||
if (masterGtid && !/^[0-9a-fA-F:,\s_-]+$/.test(masterGtid)) {
|
||||
this.logger.warn("gtid_executed del maestro con formato inesperado");
|
||||
return null;
|
||||
}
|
||||
// An empty set means the master has GTID mode off, and there is nothing
|
||||
// meaningful to compare.
|
||||
if (!masterGtid) return null;
|
||||
|
||||
const flat = masterGtid.replace(/\s+/g, "");
|
||||
// Every GTID set is flattened with REPLACE before it leaves the server.
|
||||
// MySQL wraps `gtid_executed` across lines once it holds more than one
|
||||
// source UUID, and this is read back as tab-separated columns — an
|
||||
// embedded newline would split one row into two and silently truncate the
|
||||
// set at the first UUID.
|
||||
const out = await this.onReplica(
|
||||
"SELECT REPLACE(GTID_SUBTRACT(" +
|
||||
`'${flat}', @@gtid_executed), '\\n', ''), ` +
|
||||
"@@server_uuid, REPLACE(@@gtid_executed, '\\n', '')",
|
||||
["-N"],
|
||||
);
|
||||
|
||||
// Trailing newline only — never `.trim()`. When nothing is missing the
|
||||
// first column is the empty string, so the line begins with a tab, and
|
||||
// trimming it would shift every column one position left and report the
|
||||
// replica's own UUID as the missing GTID set.
|
||||
const [missingSet = "", serverUuid = "", executed = ""] = out
|
||||
.replace(/\r?\n+$/, "")
|
||||
.split("\t");
|
||||
|
||||
return {
|
||||
missingTransactions: countGtids(missingSet),
|
||||
missingGtidSet: missingSet || null,
|
||||
localTransactions: countGtids(gtidsForUuid(executed, serverUuid)),
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.warn(`no se pudo comparar GTIDs con el maestro: ${msg}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-scan comparison of the customer-visible tables on both sides.
|
||||
*
|
||||
* Deliberately NOT part of `status()`: this reads every row of every table in
|
||||
* `REPLICATED_TABLES` on both servers, so it belongs behind a button, not a
|
||||
* 30-second poll.
|
||||
*
|
||||
* It answers the one question GTID drift cannot. GTIDs prove the replica
|
||||
* applied every transaction the master produced; they say nothing about rows
|
||||
* changed on the replica by some other route. A local write is invisible to
|
||||
* every other field on the card and shows up here as a checksum mismatch.
|
||||
*/
|
||||
async verify(): Promise<VerifyResult> {
|
||||
const started = Date.now();
|
||||
const base: VerifyResult = {
|
||||
identical: false,
|
||||
tables: [],
|
||||
problem: null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
elapsedMs: 0,
|
||||
};
|
||||
|
||||
let sql: string;
|
||||
try {
|
||||
sql = await this.fingerprintSql();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ...base, problem: `No se pudo leer el esquema: ${msg}`, elapsedMs: Date.now() - started };
|
||||
}
|
||||
|
||||
let masterOut: string;
|
||||
let replicaOut: string;
|
||||
try {
|
||||
// Sequential, not parallel. Running both at once would have the master
|
||||
// scan under the replica's own read load only sometimes, which makes a
|
||||
// slow run hard to attribute; and the boxes are small enough that two
|
||||
// concurrent full scans is a real memory event on the 946MB replica.
|
||||
masterOut = await this.onMaster(sql);
|
||||
replicaOut = await this.onReplica(sql);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return { ...base, problem: `No se pudo comparar: ${msg}`, elapsedMs: Date.now() - started };
|
||||
}
|
||||
|
||||
const master = parseFingerprints(masterOut);
|
||||
const replica = parseFingerprints(replicaOut);
|
||||
|
||||
const tables: TableFingerprint[] = REPLICATED_TABLES.map((table) => {
|
||||
const m = master.get(table);
|
||||
const r = replica.get(table);
|
||||
return {
|
||||
table,
|
||||
masterRows: m?.rows ?? -1,
|
||||
replicaRows: r?.rows ?? -1,
|
||||
masterChecksum: m?.checksum ?? "?",
|
||||
replicaChecksum: r?.checksum ?? "?",
|
||||
// Both sides must have answered. A missing row on either side is a
|
||||
// mismatch, never a pass — `undefined === undefined` would otherwise
|
||||
// report two failed reads as agreement.
|
||||
matches:
|
||||
m !== undefined && r !== undefined && m.rows === r.rows && m.checksum === r.checksum,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
identical: tables.every((t) => t.matches),
|
||||
tables,
|
||||
problem: null,
|
||||
checkedAt: base.checkedAt,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the count+checksum query from the live column list.
|
||||
*
|
||||
* The columns come from `information_schema` on the master rather than being
|
||||
* hardcoded, so the check keeps covering the whole row after a migration adds
|
||||
* one. Reading the schema from the master is safe by construction: if the two
|
||||
* schemas had diverged, replication would already be broken.
|
||||
*/
|
||||
private async fingerprintSql(): Promise<string> {
|
||||
const list = REPLICATED_TABLES.map((t) => `'${t}'`).join(",");
|
||||
const raw = await this.onMaster(
|
||||
"SELECT CONCAT(TABLE_NAME, '\\t', COLUMN_NAME) FROM information_schema.COLUMNS " +
|
||||
`WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${list}) ` +
|
||||
"ORDER BY TABLE_NAME, ORDINAL_POSITION",
|
||||
["-N"],
|
||||
);
|
||||
|
||||
const cols = new Map<string, string[]>();
|
||||
for (const line of raw.split("\n")) {
|
||||
const [table, column] = line.trim().split("\t");
|
||||
if (!table || !column) continue;
|
||||
cols.set(table, [...(cols.get(table) ?? []), column]);
|
||||
}
|
||||
|
||||
const selects = REPLICATED_TABLES.map((table) => {
|
||||
const columns = cols.get(table);
|
||||
if (!columns?.length) throw new Error(`tabla ${table} sin columnas`);
|
||||
// CONVERT(... USING binary), never CAST(... AS CHAR).
|
||||
//
|
||||
// CAST to CHAR transcodes into the *connection* character set, which is
|
||||
// not the same on the two servers: the mysql client inside the master's
|
||||
// container negotiates latin1, while the replica's negotiates utf8mb4.
|
||||
// Every accented character in a Mexican name, street or note therefore
|
||||
// hashes to different bytes on each side, and the comparison reports a
|
||||
// permanent mismatch on exactly the tables that hold free text — a
|
||||
// verification tool that always cries wolf, which is worse than none.
|
||||
// Comparing the stored bytes sidesteps the session entirely. (Verified
|
||||
// 2026-08-06: with CAST, `customers.name` gave 3344437324815 vs
|
||||
// 3339150372121; with CONVERT both give 3339150372121.)
|
||||
//
|
||||
// 0x1f (unit separator) joins the columns and 0x1e (record separator)
|
||||
// stands in for NULL. Both matter: CONCAT_WS *skips* NULLs rather than
|
||||
// emitting an empty field, so without a placeholder the rows
|
||||
// ('a', NULL, 'b') and ('a', 'b', NULL) produce the same string and a
|
||||
// column-shifting bug would checksum as identical.
|
||||
const expr = columns
|
||||
.map((c) => `IFNULL(CONVERT(\`${c}\` USING binary), 0x1e)`)
|
||||
.join(", 0x1f, ");
|
||||
// SUM, not a running hash: addition is commutative, so the result does not
|
||||
// depend on the order rows come back in. The two servers have no reason to
|
||||
// scan in the same order and are not asked to.
|
||||
return (
|
||||
`SELECT '${table}' AS t, COUNT(*) AS n, ` +
|
||||
`IFNULL(SUM(CRC32(CONCAT_WS(0x1f, ${expr}))), 0) AS c FROM \`${table}\``
|
||||
);
|
||||
});
|
||||
|
||||
return selects.join(" UNION ALL ");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ plumbing */
|
||||
|
||||
/**
|
||||
* Run a statement on the replica.
|
||||
*
|
||||
* --ssl is required: the replica sets require_secure_transport=ON.
|
||||
*
|
||||
* --ssl-verify-server-cert=0 is deliberate and is NOT the same trade-off the
|
||||
* website makes. This hop never leaves Tailscale — the replica is reached on
|
||||
* its CGNAT tailnet address and the tailnet ACL admits only this host — so
|
||||
* WireGuard already authenticates the peer. The DreamHost leg crosses the
|
||||
* public internet and therefore pins the CA instead. The client here is
|
||||
* MariaDB's, which rejects our self-signed CA outright unless it is handed the
|
||||
* CA file, which would mean shipping a cert into this image for a link that is
|
||||
* already authenticated.
|
||||
*/
|
||||
private async onReplica(sql: string, extra: string[] = []): Promise<string> {
|
||||
const host = process.env.REPLICA_DB_HOST!;
|
||||
const user = process.env.REPLICA_DB_USER!;
|
||||
const password = process.env.REPLICA_DB_PASS!;
|
||||
const { stdout } = await exec(
|
||||
"mysql",
|
||||
[
|
||||
`--host=${host}`,
|
||||
`--user=${user}`,
|
||||
"--ssl",
|
||||
"--ssl-verify-server-cert=0",
|
||||
"--connect-timeout=5",
|
||||
...extra,
|
||||
"-e",
|
||||
sql,
|
||||
],
|
||||
{ env: { ...process.env, MYSQL_PWD: password }, timeout: VERIFY_TIMEOUT_MS },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a statement on the master, using the application's own DATABASE_URL.
|
||||
*
|
||||
* The app credential is enough here on purpose — everything this class sends
|
||||
* to the master is a SELECT against `information_schema` or a system variable.
|
||||
* Reaching for OPS_DB_ADMIN_* the way OpsService does would hand a monitoring
|
||||
* read path a credential that can also restore a dump.
|
||||
*/
|
||||
private async onMaster(sql: string, extra: string[] = []): Promise<string> {
|
||||
const raw = process.env.DATABASE_URL;
|
||||
if (!raw) throw new Error("DATABASE_URL no está configurada");
|
||||
const u = new URL(raw);
|
||||
const { stdout } = await exec(
|
||||
"mysql",
|
||||
[
|
||||
`--host=${u.hostname}`,
|
||||
`--port=${u.port || "3306"}`,
|
||||
`--user=${decodeURIComponent(u.username)}`,
|
||||
"--connect-timeout=5",
|
||||
...extra,
|
||||
"-N",
|
||||
"-e",
|
||||
sql,
|
||||
u.pathname.replace(/^\//, ""),
|
||||
],
|
||||
{
|
||||
env: { ...process.env, MYSQL_PWD: decodeURIComponent(u.password) },
|
||||
timeout: VERIFY_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
}
|
||||
|
||||
/** Full scans on a 1-vCPU replica are not fast; 15s would cut them off. */
|
||||
const VERIFY_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Parse the `t\tn\tc` rows the fingerprint query emits under `mysql -N`. */
|
||||
function parseFingerprints(raw: string): Map<string, { rows: number; checksum: string }> {
|
||||
const out = new Map<string, { rows: number; checksum: string }>();
|
||||
for (const line of raw.split("\n")) {
|
||||
const [table, n, c] = line.trim().split("\t");
|
||||
if (!table || n === undefined || c === undefined) continue;
|
||||
const rows = Number(n);
|
||||
if (!Number.isFinite(rows)) continue;
|
||||
// The checksum stays a string. Sums of CRC32 over 40k rows exceed 2^53, so
|
||||
// parsing it as a number would round and make distinct tables compare equal.
|
||||
out.set(table, { rows, checksum: c });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the transactions in a GTID set.
|
||||
*
|
||||
* Exported for testing. The format is `uuid[:tag]:interval[:interval]...`,
|
||||
* comma-separated, where an interval is `N` or `N-M` inclusive at both ends —
|
||||
* so `1-5` is five transactions, not four.
|
||||
*
|
||||
* MySQL 8.4 added an optional alphanumeric tag between the UUID and the first
|
||||
* interval. It is skipped rather than parsed: any segment that is not a number
|
||||
* or a number range is not an interval, whatever else it may be.
|
||||
*/
|
||||
export function countGtids(set: string): number {
|
||||
if (!set.trim()) return 0;
|
||||
let total = 0;
|
||||
for (const group of set.split(",")) {
|
||||
for (const part of group.trim().split(":").slice(1)) {
|
||||
const m = /^(\d+)(?:-(\d+))?$/.exec(part.trim());
|
||||
if (!m) continue;
|
||||
const from = Number(m[1]);
|
||||
const to = m[2] === undefined ? from : Number(m[2]);
|
||||
if (Number.isFinite(from) && Number.isFinite(to) && to >= from) total += to - from + 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a GTID set to the intervals belonging to one server UUID.
|
||||
*
|
||||
* Exported for testing. Used to isolate the replica's own writes from the
|
||||
* history it replicated, which are interleaved in the same `gtid_executed`.
|
||||
*/
|
||||
export function gtidsForUuid(set: string, uuid: string): string {
|
||||
if (!uuid.trim()) return "";
|
||||
const wanted = uuid.trim().toLowerCase();
|
||||
return set
|
||||
.split(",")
|
||||
.map((g) => g.trim())
|
||||
.filter((g) => g.toLowerCase().startsWith(`${wanted}:`))
|
||||
.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { applyProgress, replicaField } from "./replication.service";
|
||||
import {
|
||||
applyProgress,
|
||||
countGtids,
|
||||
gtidsForUuid,
|
||||
replicaField,
|
||||
} from "./replication.service";
|
||||
|
||||
/**
|
||||
* Verbatim shape of `SHOW REPLICA STATUS\G` from the live replica, trimmed to
|
||||
@@ -178,3 +183,72 @@ describe("applyProgress", () => {
|
||||
expect(applyProgress(positions("binlog.000042", "NULL", "binlog.000042", 400))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Real GTID sets from the live pair, captured 2026-08-06. The replica's own
|
||||
* server UUID (3b103283…) carries the transactions the seed dump load executed
|
||||
* locally; the master's UUID (defc34e2…) carries the replicated history.
|
||||
*/
|
||||
const REPLICA_EXECUTED =
|
||||
"3b103283-8f15-11f1-a52b-020017027b33:1-513," +
|
||||
"defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-525";
|
||||
const REPLICA_UUID = "3b103283-8f15-11f1-a52b-020017027b33";
|
||||
|
||||
describe("countGtids", () => {
|
||||
it("counts an inclusive range at both ends", () => {
|
||||
// 1-5 is five transactions. Off-by-one here understates the gap, which is
|
||||
// the direction that hides a problem.
|
||||
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-5")).toBe(5);
|
||||
});
|
||||
|
||||
it("counts a bare single transaction", () => {
|
||||
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:7")).toBe(1);
|
||||
});
|
||||
|
||||
it("sums several intervals under one UUID", () => {
|
||||
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-5:8:10-12")).toBe(9);
|
||||
});
|
||||
|
||||
it("sums across UUIDs, including the wrapped form MySQL prints", () => {
|
||||
expect(countGtids(REPLICA_EXECUTED)).toBe(513 + 525);
|
||||
// `gtid_executed` comes back wrapped once it holds more than one UUID.
|
||||
expect(countGtids(REPLICA_EXECUTED.replace(",", ",\n"))).toBe(513 + 525);
|
||||
});
|
||||
|
||||
/** An empty subtraction result is the caught-up case and must be zero. */
|
||||
it("returns 0 for an empty or blank set", () => {
|
||||
expect(countGtids("")).toBe(0);
|
||||
expect(countGtids(" \n ")).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* MySQL 8.4 allows an alphanumeric tag between the UUID and the intervals.
|
||||
* It is not an interval and must not be counted as one.
|
||||
*/
|
||||
it("skips a tag without counting it", () => {
|
||||
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:mytag:1-3")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gtidsForUuid", () => {
|
||||
it("isolates the replica's own transactions from the replicated history", () => {
|
||||
expect(countGtids(gtidsForUuid(REPLICA_EXECUTED, REPLICA_UUID))).toBe(513);
|
||||
});
|
||||
|
||||
it("returns nothing for a UUID that is not in the set", () => {
|
||||
expect(gtidsForUuid(REPLICA_EXECUTED, "00000000-0000-0000-0000-000000000000")).toBe("");
|
||||
});
|
||||
|
||||
/**
|
||||
* The colon matters. Without it a UUID prefix would match a longer UUID that
|
||||
* merely starts the same way, and the replica's local writes would be
|
||||
* over-reported.
|
||||
*/
|
||||
it("does not match on a bare prefix", () => {
|
||||
expect(gtidsForUuid(REPLICA_EXECUTED, "3b103283")).toBe("");
|
||||
});
|
||||
|
||||
it("returns nothing when the UUID is blank", () => {
|
||||
expect(gtidsForUuid(REPLICA_EXECUTED, "")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsEnum, IsOptional, IsString } from "class-validator";
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
|
||||
import { OpsJobKind } from "@jorgecuadros/database";
|
||||
|
||||
export class StartJobDto {
|
||||
@@ -9,4 +9,13 @@ export class StartJobDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
file?: string;
|
||||
|
||||
/**
|
||||
* REIMPORT only: proceed even though the rebuild deletes rows that exist only
|
||||
* in the platform. Off by default, so the guard in run_all.py stops the job
|
||||
* and lists what would be lost rather than the operator finding out after.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
forceFull?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { PoliciesService } from "./policies.service";
|
||||
|
||||
/**
|
||||
* Deleting a lookup row that policies still reference used to succeed and
|
||||
* silently blank the field on every one of them, because both FKs are
|
||||
* `ON DELETE SET NULL` (`0000_init`). That is not a hypothetical: it is how
|
||||
* the `M_EMPR` policy type disappeared from the dev database and left 5
|
||||
* policies with a null `policyTypeId`, found only by querying months later.
|
||||
*
|
||||
* These tests pin the refusal. They drive the service with a stub client
|
||||
* rather than a database because what is being asserted is the guard, not
|
||||
* Prisma — and a test that needed a live MySQL would not run in CI.
|
||||
*/
|
||||
function serviceWith(counts: {
|
||||
policies?: number;
|
||||
claims?: number;
|
||||
}): { service: PoliciesService; deleted: string[] } {
|
||||
const deleted: string[] = [];
|
||||
const prisma = {
|
||||
policy: { count: async () => counts.policies ?? 0 },
|
||||
claim: { count: async () => counts.claims ?? 0 },
|
||||
insuranceProvider: {
|
||||
findUnique: async () => ({ id: "p1", name: "ANA SEGUROS" }),
|
||||
delete: async () => {
|
||||
deleted.push("provider");
|
||||
return { id: "p1" };
|
||||
},
|
||||
},
|
||||
policyType: {
|
||||
findUnique: async () => ({ id: "t1", name: "M_EMPR" }),
|
||||
delete: async () => {
|
||||
deleted.push("policyType");
|
||||
return { id: "t1" };
|
||||
},
|
||||
},
|
||||
adjuster: {
|
||||
findUnique: async () => ({ id: "a1", name: "JUAN PEREZ" }),
|
||||
delete: async () => {
|
||||
deleted.push("adjuster");
|
||||
return { id: "a1" };
|
||||
},
|
||||
},
|
||||
};
|
||||
const storage = {} as never;
|
||||
return {
|
||||
service: new PoliciesService(prisma as never, storage),
|
||||
deleted,
|
||||
};
|
||||
}
|
||||
|
||||
describe("lookup deletes refuse while the row is in use", () => {
|
||||
it("refuses a policy type that policies still carry, and names the count", () => {
|
||||
const { service, deleted } = serviceWith({ policies: 5 });
|
||||
return service.removePolicyType("t1").then(
|
||||
() => {
|
||||
throw new Error("expected the delete to be refused");
|
||||
},
|
||||
(err: unknown) => {
|
||||
expect(err).toBeInstanceOf(BadRequestException);
|
||||
// The operator has to be told WHICH row and HOW MANY, or the message
|
||||
// is not actionable.
|
||||
expect((err as Error).message).toContain("M_EMPR");
|
||||
expect((err as Error).message).toContain("5");
|
||||
expect(deleted).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a carrier that policies still carry", async () => {
|
||||
const { service, deleted } = serviceWith({ policies: 738 });
|
||||
await expect(service.removeProvider("p1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an adjuster still assigned to claims", async () => {
|
||||
// Same `ON DELETE SET NULL` trap, on `claims.adjusterId`.
|
||||
const { service, deleted } = serviceWith({ claims: 2 });
|
||||
await expect(service.removeAdjuster("a1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows the delete once nothing references the row", async () => {
|
||||
const { service, deleted } = serviceWith({ policies: 0, claims: 0 });
|
||||
await service.removePolicyType("t1");
|
||||
await service.removeProvider("p1");
|
||||
await service.removeAdjuster("a1");
|
||||
expect(deleted).toEqual(["policyType", "provider", "adjuster"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
@@ -554,7 +554,40 @@ export class PoliciesService {
|
||||
updateProvider(id: string, dto: UpdateProviderDto) {
|
||||
return this.prisma.insuranceProvider.update({ where: { id }, data: dto });
|
||||
}
|
||||
removeProvider(id: string) {
|
||||
/**
|
||||
* Deleting a lookup row that policies still point at is silent data loss.
|
||||
*
|
||||
* Both FKs are `ON DELETE SET NULL` (see `0000_init`), so the delete
|
||||
* succeeds, returns 200, and blanks the field on every policy that used it
|
||||
* — with no error and nothing in the UI to suggest anything happened. That
|
||||
* is how the `M_EMPR` policy type disappeared and left 5 policies with a
|
||||
* null `policyTypeId`, only found later by querying.
|
||||
*
|
||||
* Refusing is the whole fix. There is no "are you sure": the operator
|
||||
* reassigns those policies first, which is work the app cannot do for them
|
||||
* because only they know which type is correct.
|
||||
*/
|
||||
private async assertLookupUnused(
|
||||
kind: "provider" | "policyType",
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const where = kind === "provider" ? { insuranceProviderId: id } : { policyTypeId: id };
|
||||
const count = await this.prisma.policy.count({ where });
|
||||
if (count === 0) return;
|
||||
|
||||
const label =
|
||||
kind === "provider"
|
||||
? (await this.prisma.insuranceProvider.findUnique({ where: { id } }))?.name
|
||||
: (await this.prisma.policyType.findUnique({ where: { id } }))?.name;
|
||||
const noun = kind === "provider" ? "La aseguradora" : "El tipo de póliza";
|
||||
throw new BadRequestException(
|
||||
`${noun} «${label ?? id}» está en uso por ${count} póliza(s). ` +
|
||||
"Reasígnelas antes de eliminarlo.",
|
||||
);
|
||||
}
|
||||
|
||||
async removeProvider(id: string) {
|
||||
await this.assertLookupUnused("provider", id);
|
||||
return this.prisma.insuranceProvider.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -564,7 +597,8 @@ export class PoliciesService {
|
||||
updatePolicyType(id: string, dto: UpdatePolicyTypeDto) {
|
||||
return this.prisma.policyType.update({ where: { id }, data: dto });
|
||||
}
|
||||
removePolicyType(id: string) {
|
||||
async removePolicyType(id: string) {
|
||||
await this.assertLookupUnused("policyType", id);
|
||||
return this.prisma.policyType.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -574,7 +608,17 @@ export class PoliciesService {
|
||||
updateAdjuster(id: string, dto: UpdateAdjusterDto) {
|
||||
return this.prisma.adjuster.update({ where: { id }, data: dto });
|
||||
}
|
||||
removeAdjuster(id: string) {
|
||||
/** Same `ON DELETE SET NULL` trap as the two above, on `claims.adjusterId`:
|
||||
* deleting a busy adjuster would quietly strip them off their claims. */
|
||||
async removeAdjuster(id: string) {
|
||||
const count = await this.prisma.claim.count({ where: { adjusterId: id } });
|
||||
if (count > 0) {
|
||||
const row = await this.prisma.adjuster.findUnique({ where: { id } });
|
||||
throw new BadRequestException(
|
||||
`El ajustador «${row?.name ?? id}» está asignado a ${count} siniestro(s). ` +
|
||||
"Reasígnelos antes de eliminarlo.",
|
||||
);
|
||||
}
|
||||
return this.prisma.adjuster.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ function page(text: string): OcrPage {
|
||||
return { text, words: [], confidence: 0.95 };
|
||||
}
|
||||
|
||||
/** Coverages keyed by their risk label, so an assertion names the coverage
|
||||
* it is about instead of an array index that shifts when one is added. */
|
||||
const byRisk = (p: ReturnType<typeof parsePolicy>): Record<string, ParsedCoverage> =>
|
||||
Object.fromEntries(p.coverages.map((c) => [c.risk, c]));
|
||||
|
||||
describe("detectPolicyProvider", () => {
|
||||
it("claims GMX from the brand wordmark on the letterhead", () => {
|
||||
expect(
|
||||
@@ -111,6 +116,13 @@ describe("parsePolicy / GMX", () => {
|
||||
expect(p.coverages.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("names the product MULT for confirm to resolve", () => {
|
||||
// The caratula's own header reads "Multiple Policy / Home". MULT is the
|
||||
// legacy discriminator for that multi-line home policy; INCENDIO is
|
||||
// fire-only and no policy in the book has ever used it.
|
||||
expect(parsePolicy(GMX_FULL).policyTypeName).toBe("MULT");
|
||||
});
|
||||
|
||||
it("leaves premium fields null on the certificate page and notes it", () => {
|
||||
const p = parsePolicy(GMX_FULL);
|
||||
expect(p.netPremium).toBeNull();
|
||||
@@ -145,3 +157,754 @@ describe("parsePolicy / GMX", () => {
|
||||
expect(eqTyped.lossParticipation).toBe("20%");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The second GMX document family: the Spanish PVL "especificación" the office
|
||||
* receives as `…-CondicionesParticulares.pdf`. Verbatim excerpts from
|
||||
* `007_LGS-HGMX_07006957_01_0-CondicionesParticulares.pdf` through
|
||||
* `pdftotext -layout`, indentation included — the column positions and the
|
||||
* blank lines between blocks are what the parser reads, so a cleaned-up
|
||||
* fixture would test nothing.
|
||||
*/
|
||||
describe("parsePolicy / GMX especificación (PVL Hogar)", () => {
|
||||
const HEADER =
|
||||
" ESPECIFICACIÓN QUE SE ADHIERE Y FORMA PARTE INTEGRANTE DE LA PÓLIZA\n" +
|
||||
" 07-037-07006957-00000-01\n" +
|
||||
"\n";
|
||||
|
||||
const GMX_ESPEC = page(
|
||||
HEADER +
|
||||
"\n" +
|
||||
" Nombre del asegurado EMMER . KATHLEEN\n" +
|
||||
"\n" +
|
||||
" Tipo Persona Asegurada Propietario\n" +
|
||||
"\n" +
|
||||
" Ubicación del riesgo LOS PELICANOS ESTE NO. 98 Col. LAS GAVIOTAS PLAYAS\n" +
|
||||
" DE ROSARITO BAJA CALIFORNIA 22713\n" +
|
||||
"\n" +
|
||||
" Características del Inmueble Casa Tipo constructivo Combinado: Macizo y Madera.\n" +
|
||||
" Consta de 2 pisos incluyendo sótanos y planta baja.\n" +
|
||||
"\n" +
|
||||
" -500 mts.cuerpo agua SI\n" +
|
||||
"\n" +
|
||||
" Asegurado Adicional\n" +
|
||||
"\n" +
|
||||
"PVL Hogar - GMX Seguros Página: 1 de 10\n" +
|
||||
HEADER +
|
||||
"\n" +
|
||||
" SECCIÓN INCENDIO EDIFICIO Y CONTENIDOS\n" +
|
||||
"\n" +
|
||||
" EDIFICIO\n" +
|
||||
"\n" +
|
||||
" Límite Máximo de Responsabilidad:\n" +
|
||||
" $200,000.00 USD\n" +
|
||||
"\n" +
|
||||
" Quedan amparados los muros de contención y bardas, así como puertas y portones, hasta un sublimite de $ 50,000.00 M.N. o su\n" +
|
||||
" equivalente en dólares americanos, o hasta el 10% de la suma asegurada de la sección de Edificio, lo que resulte menor.\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" CONTENIDOS\n" +
|
||||
"\n" +
|
||||
" Límite Máximo de Responsabilidad:\n" +
|
||||
" $20,000.00 USD\n" +
|
||||
"\n" +
|
||||
" 2. Terremoto o erupción volcánica: Sección Edificio EXCLUIDO, Sección Contenidos EXCLUIDO\n" +
|
||||
"\n" +
|
||||
" 3. Fenómenos hidrometeorológicos: Sección Edificio $200,000.00 USD, Sección Contenidos $20,000.00 USD\n" +
|
||||
"\n" +
|
||||
" Riesgos adicionales.\n" +
|
||||
"\n" +
|
||||
" Remoción de escombros\n" +
|
||||
"\n" +
|
||||
" Límite Máximo de Responsabilidad:\n" +
|
||||
" Edificio\n" +
|
||||
" $20,000.00 USD\n" +
|
||||
" Contenidos\n" +
|
||||
" $2,000.00 USD\n" +
|
||||
"\n" +
|
||||
" Gastos extraordinarios para casa habitación\n" +
|
||||
"\n" +
|
||||
" En caso de siniestro por los riesgos cubiertos en esta póliza, GMX Seguros pagará la renta de casa o departamento, casa de\n" +
|
||||
" huéspedes u hotel cuando se asegure el inmueble, así como los gastos de mudanza, seguro de transporte del menaje de casa y\n" +
|
||||
" efectuados.\n" +
|
||||
"\n" +
|
||||
" Límite Máximo de Responsabilidad:\n" +
|
||||
" $22,000.00 USD\n" +
|
||||
" Periodo de indemnización: 4 meses.\n" +
|
||||
"\n" +
|
||||
" Bienes a la Intemperie:\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" 5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO DEL ASEGURADO DE TODA\n" +
|
||||
" Y CADA PÉRDIDA.\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" Límite Máximo de Responsabilidad: $10,000.00 USD\n" +
|
||||
"\n" +
|
||||
" DEDUCIBLES:\n" +
|
||||
"\n" +
|
||||
" El procedimiento que se seguirá para la aplicación de deducibles en caso de que la póliza cuente con cláusula inflacionaria en todas\n" +
|
||||
" y/o en algunas de sus coberturas será como sigue:\n" +
|
||||
"\n" +
|
||||
" Fenómenos hidrometeorológicos\n" +
|
||||
" Zona: A2\n" +
|
||||
" Deducible\n" +
|
||||
" Edificio: 1 POR CIENTO SOBRE SUMA ASEGURADA\n" +
|
||||
" Coaseguro:\n" +
|
||||
" Zona 1: (INTERIOR) Participación a cargo del asegurado del 10% de toda y cada pérdida.\n" +
|
||||
" Zona 2: Participación a cargo del asegurado del 10% de toda y cada pérdida.\n" +
|
||||
"\n" +
|
||||
" Deducible\n" +
|
||||
" Contenidos: 1 POR CIENTO SOBRE SUMA ASEGURADA\n" +
|
||||
"\n" +
|
||||
" II.- SECCIÓN DIVERSOS MISCELÁNEOS\n" +
|
||||
"\n" +
|
||||
" ROBO DE CONTENIDOS\n" +
|
||||
"\n" +
|
||||
" Límite de Responsabilidad:\n" +
|
||||
" $4,000.00 USD\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" Deducible:\n" +
|
||||
" Sin deducible\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" Sublímites:\n" +
|
||||
" Joyas, artículos de oro y plata, armas, relojes, pieles, piedras preciosas montadas, colecciones, obras de arte y demás que por su\n" +
|
||||
"\n" +
|
||||
"PVL Hogar - GMX Seguros Página: 7 de 10\n" +
|
||||
HEADER +
|
||||
"\n" +
|
||||
" naturaleza se consideran como objetos de difícil o imposible reposición\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" Límite de Responsabilidad:\n" +
|
||||
"\n" +
|
||||
" $2,000.00 USD\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
" Deducible:\n" +
|
||||
" Sin deducible\n" +
|
||||
"\n" +
|
||||
" Las condiciones generales que forman parte de la presente póliza son las identificadas bajo el nombre:\n" +
|
||||
" W_HogarGMX_12.11.2025.pdf\n" +
|
||||
"\n" +
|
||||
"PVL Hogar - GMX Seguros Página: 10 de 10\n",
|
||||
);
|
||||
|
||||
it("reads a policy number whose groups are not the caratula's widths", () => {
|
||||
// 2-3-8-5-2 here vs 3-3-8-4-2 on the English caratula. Pinning the widths
|
||||
// reads one family and returns null on the other.
|
||||
expect(parsePolicy(GMX_ESPEC).policyNumber).toBe("07-037-07006957-00000-01");
|
||||
});
|
||||
|
||||
it("reads the insured, the risk location across its wrapped line, and the ZIP", () => {
|
||||
const p = parsePolicy(GMX_ESPEC);
|
||||
expect(p.provider).toBe("GMX");
|
||||
expect(p.insuredName).toBe("EMMER . KATHLEEN");
|
||||
expect(p.legalAddress).toBe(
|
||||
"LOS PELICANOS ESTE NO. 98 Col. LAS GAVIOTAS PLAYAS DE ROSARITO BAJA CALIFORNIA 22713",
|
||||
);
|
||||
expect(p.zip).toBe("22713");
|
||||
// The cell is printed but empty on this policy — an empty label must not
|
||||
// capture the next line of the form.
|
||||
expect(p.additionalInsured).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves the fields this document does not carry null, and says so", () => {
|
||||
const p = parsePolicy(GMX_ESPEC);
|
||||
expect(p.policyFrom).toBeNull();
|
||||
expect(p.policyTo).toBeNull();
|
||||
expect(p.policyDate).toBeNull();
|
||||
expect(p.agentName).toBeNull();
|
||||
expect(p.netPremium).toBeNull();
|
||||
expect(p.total).toBeNull();
|
||||
// The note must tell the reviewer to key them in — those three are
|
||||
// captured by hand on this layout — and must say what silently breaks if
|
||||
// the vigencia is left empty.
|
||||
const notes = p.notes.join(" ");
|
||||
expect(notes).toMatch(/no trae vigencia, agente ni prima/i);
|
||||
expect(notes).toMatch(/captúrelos a mano/i);
|
||||
expect(notes).toMatch(/avisos de renovación/i);
|
||||
});
|
||||
|
||||
it("takes the currency from the printed limits, not from the M.N. sublimits", () => {
|
||||
// The body prose quotes sublimits in pesos ("$ 50,000.00 M.N."); every
|
||||
// limit is in USD, and only the limits vote.
|
||||
expect(parsePolicy(GMX_ESPEC).currency).toBe("USD");
|
||||
});
|
||||
|
||||
it("reads each coverage under its own heading", () => {
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c.EDIFICIO?.insuredAmount).toBe(200000);
|
||||
expect(c.CONTENIDOS?.insuredAmount).toBe(20000);
|
||||
expect(c["ROBO DE CONTENIDOS"]?.insuredAmount).toBe(4000);
|
||||
expect(c["ROBO DE CONTENIDOS"]?.deductible).toBe("Sin deducible");
|
||||
});
|
||||
|
||||
it("splits a limit printed under Edificio / Contenidos sub-labels", () => {
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c["Remoción de escombros — Edificio"]?.insuredAmount).toBe(20000);
|
||||
expect(c["Remoción de escombros — Contenidos"]?.insuredAmount).toBe(2000);
|
||||
});
|
||||
|
||||
it("names a coverage after its heading, not after the wrapped tail of the prose above it", () => {
|
||||
// Walking back from the limit hits "efectuados." — short, and the only
|
||||
// thing separating it from a heading is that it is not preceded by a
|
||||
// blank line.
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c["Gastos extraordinarios para casa habitación"]?.insuredAmount).toBe(22000);
|
||||
expect(c["efectuados."]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads a limit printed on the label's own line", () => {
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c["Bienes a la Intemperie"]?.insuredAmount).toBe(10000);
|
||||
});
|
||||
|
||||
it("reads a deductible stated as a sentence above the limit", () => {
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c["Bienes a la Intemperie"]?.deductible).toBe(
|
||||
"5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO DEL ASEGURADO DE TODA Y CADA PÉRDIDA.",
|
||||
);
|
||||
});
|
||||
|
||||
it("never borrows a neighbouring coverage's prose as a deductible", () => {
|
||||
// "…o hasta el 10% de la suma asegurada de la sección de Edificio" is a
|
||||
// sublimit rule for EDIFICIO, printed two paragraphs above CONTENIDOS.
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c.CONTENIDOS?.deductible).toBeNull();
|
||||
expect(c.EDIFICIO?.deductible).toBeNull();
|
||||
});
|
||||
|
||||
it("does not read the page-level DEDUCIBLES paragraph as a deductible", () => {
|
||||
const p = parsePolicy(GMX_ESPEC);
|
||||
expect(
|
||||
p.coverages.some((c) => (c.deductible ?? "").includes("cláusula inflacionaria")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a sublimit block as a sublimit OF the coverage above it", () => {
|
||||
// The amount sits after a blank line AND a page break, and the block's
|
||||
// own heading ("Sublímites:") names no risk.
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
expect(c["ROBO DE CONTENIDOS — sublímite"]?.insuredAmount).toBe(2000);
|
||||
});
|
||||
|
||||
it("records an excluded catastrophic risk as excluded, never as zero", () => {
|
||||
const p = parsePolicy(GMX_ESPEC);
|
||||
const quake = p.coverages.filter((c) => /Terremoto/i.test(c.risk));
|
||||
expect(quake).toHaveLength(2);
|
||||
for (const c of quake) {
|
||||
expect(c.risk).toMatch(/EXCLUIDO/);
|
||||
// A coverage insured for $0 and an excluded coverage are the same
|
||||
// number and very different facts.
|
||||
expect(c.insuredAmount).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches the hydrometeorological deductible and coinsurance from its own block", () => {
|
||||
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||
const building = c["Fenómenos hidrometeorológicos — Sección Edificio"];
|
||||
expect(building?.insuredAmount).toBe(200000);
|
||||
expect(building?.deductible).toBe("1 POR CIENTO SOBRE SUMA ASEGURADA");
|
||||
expect(building?.lossParticipation).toBe("10%");
|
||||
expect(c["Fenómenos hidrometeorológicos — Sección Contenidos"]?.insuredAmount).toBe(20000);
|
||||
});
|
||||
|
||||
it("names the same product as the caratula — one policy, two artifacts", () => {
|
||||
expect(parsePolicy(GMX_ESPEC).policyTypeName).toBe("MULT");
|
||||
});
|
||||
|
||||
it("carries the underwriting context the fields have no home for", () => {
|
||||
const notes = parsePolicy(GMX_ESPEC).notes.join(" | ");
|
||||
expect(notes).toMatch(/tipo de persona asegurada: Propietario/);
|
||||
expect(notes).toMatch(/características del inmueble: Casa/);
|
||||
expect(notes).toMatch(/cuerpo de agua/);
|
||||
expect(notes).toMatch(/zona catastrófica declarada: A2/);
|
||||
expect(notes).toMatch(/W_HogarGMX_12\.11\.2025\.pdf/);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ ANA */
|
||||
|
||||
/**
|
||||
* Verbatim `pdftotext -layout` output of the PDFs A.N.A.'s portal produced
|
||||
* for three real policies, cut at the end of the risk table (the legal
|
||||
* boilerplate and the repeated AGENT COPY below it are not parsed, and the
|
||||
* repeats are covered by their own test).
|
||||
*
|
||||
* The column padding is load-bearing on the driver's policy, which
|
||||
* distinguishes SUM INSURED from PREMIUM by horizontal position alone — do
|
||||
* not reflow these strings.
|
||||
*/
|
||||
|
||||
const ANA_AUTO_AMPLIA = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV
|
||||
LUIS CABRERA #2033 INT. 201, Col. ZONA URBANA RIO TIJUANA
|
||||
C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA
|
||||
www.anaseguros.com.mx
|
||||
AUTOMOBILE
|
||||
ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO
|
||||
U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9 SPECIAL POLICY FOR TOURISTS
|
||||
TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266
|
||||
WHATSAPP + 52-55-80-50-3633
|
||||
No. 700489651
|
||||
ISSUED BY: DATE ISSUED TERM OF INSURANCE
|
||||
DAYS
|
||||
JORGE HUMBERTO CUADROS DAY MONTH YEAR DAY MONTH YEAR TIME
|
||||
BENITO JUAREZ 25 No.50 INT 38 CENTRO
|
||||
04 08 2026 FROM 07 08 2026 12:01
|
||||
365
|
||||
ROSARITO, BAJA CALIFORNIA 22710
|
||||
. 70175 TO 07 08 2027 12:01
|
||||
DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL
|
||||
- 298.61 30.00 26.29 0.00 354.90
|
||||
|
||||
INSURED RAY DEAN II AND SUSAN ROCKHOLD
|
||||
LICENSE P0066762
|
||||
ADDRESS 10308 DONNA AVE EMAIL PROLABSALE@AOL.COM
|
||||
CITY & STATE NORTHRIDGE, CA 91326 TELEPHONE 8184453524
|
||||
PAYMENT DEADLINE
|
||||
INSURANCE COMPANY LIEN HOLDER
|
||||
IMMEDIATE
|
||||
|
||||
ITEM YEAR MAKE BODY SERIAL No. PLATES
|
||||
VEHICLE 2017 CHRYSLER PACIFICA 2C4RC1DG7HR654698 8BPX206
|
||||
TRAILER . .
|
||||
TOWING . .
|
||||
*** VALUE STATED MUST NOT EXCEED MARKET VALUE ***
|
||||
***VEHICLES THAT HAVE BEEN ACQUIRED AS SALVAGE, REBUILT, OR HAVE BEEN USED PREVIOUSLY AS A TAXI WILL BE CONSIDERED WITH A REDUCED VALUE OF 35% (thirty-five percent), TAKING
|
||||
AS A BASE THE VALUE OF A SIMILAR NORMAL VEHICLE, THAT IS, ONE THAT HAS NOT BEEN ACQUIRED AS SALVAGE AND ITS PREVIOUS USE HAS NOT BEEN AS A TAXI OR REBUILT. IT WILL BE THE
|
||||
SOLE OBLIGATION AND RESPONSIBILITY OF THE INSURED TO DECLARATE THIS WHEN ACQUIRING THE POLICY.
|
||||
SECTION SPECIFICATION OF RISKS LIMIT OF LIABILITY
|
||||
MATERIAL DAMAGE WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED VEHICLE 8,000.00 DLLS.
|
||||
1 DEDUCTIBLE: WITH MINIMUM OF $500.00 ON AUTOS
|
||||
TRAILER
|
||||
(SEDANS, COUPES, CONVERTIBLES AND STATION WAGONS) COVERED
|
||||
AND $500.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND MOTOR HOMES). 0.00 DLLS.
|
||||
|
||||
TOTAL THEFT WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED TOWING
|
||||
2 DEDUCTIBLE: WITH MINIMUM OF $1,000.00 ON AUTOS 0.00 DLLS.
|
||||
(SEDANS, COUPES, CONVERTIBLES AND STATION WAGONS) COVERED
|
||||
AND $1,000.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND MOTOR HOMES).
|
||||
LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES
|
||||
3 100,000.00 DLLS.
|
||||
|
||||
|
||||
BODILY INJURY LIABILITY PER PER
|
||||
4 PERSON 100,000.00 ACCIDENT 200,000.00 DLLS.
|
||||
|
||||
MEDICAL EXPENSES PER PER
|
||||
5 PERSON 5,000.00 ACCIDENT 25,000.00 DLLS.
|
||||
|
||||
COVERED/EXCLUDED PREMIUM
|
||||
6 A.N.A.'s LEGAL AID
|
||||
COVERED 40.00
|
||||
COVERED/EXCLUDED PREMIUM
|
||||
7 A.N.A.'s ROADSIDE ASSISTANCE
|
||||
COVERED 40.00
|
||||
CATASTROPHIC LIABILITY FOR DEATH OF THIRD PREMIUM
|
||||
8 EXCLUDED
|
||||
PARTIES DLLS. 0.00
|
||||
ELITE OR ELITE PLUS WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED
|
||||
9 PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) 0.00
|
||||
VANDALISM (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) EXCLUDED
|
||||
|
||||
|
||||
ISSUED ONLINE`);
|
||||
|
||||
const ANA_AUTO_RC_DIAS = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV
|
||||
LUIS CABRERA #2033 INT. 201, Col. ZONA URBANA RIO TIJUANA
|
||||
C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA
|
||||
www.anaseguros.com.mx
|
||||
AUTOMOBILE
|
||||
ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO
|
||||
U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9 SPECIAL POLICY FOR TOURISTS
|
||||
TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266
|
||||
WHATSAPP + 52-55-80-50-3633
|
||||
No. 700487807
|
||||
ISSUED BY: DATE ISSUED TERM OF INSURANCE
|
||||
DAYS
|
||||
JORGE HUMBERTO CUADROS DIARIA DAY MONTH YEAR DAY MONTH YEAR TIME
|
||||
BENITO JUAREZ 25 NO50 INT 38 COL CENTRO
|
||||
22 07 2026 FROM 23 07 2026 12:01
|
||||
3
|
||||
ROSARITO BAJA CALIFORNIA 22710
|
||||
(661) 612 12 55 70175 TO 26 07 2026 12:01
|
||||
DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL
|
||||
- 10.77 25.00 2.86 0.00 38.63
|
||||
|
||||
INSURED STEPHEN RUPAN SHATAFIAN
|
||||
LICENSE C1394198
|
||||
ADDRESS 13181 CROSSROADS PARKWAY NORTH STE 300 EMAIL sshatafian@lee-associates.com
|
||||
|
||||
|
||||
CITY & STATE CITY OF INDUSTRY, CA 91746 TELEPHONE 7143221072
|
||||
PAYMENT DEADLINE
|
||||
INSURANCE COMPANY LIEN HOLDER
|
||||
IMMEDIATE
|
||||
|
||||
ITEM YEAR MAKE BODY SERIAL No. PLATES
|
||||
VEHICLE 2022 FORD TRANSIT 1FBAX2CG3NKA69091 EC46T99
|
||||
TRAILER . .
|
||||
TOWING . .
|
||||
*** VALUE STATED MUST NOT EXCEED MARKET VALUE ***
|
||||
***VEHICLES THAT HAVE BEEN ACQUIRED AS SALVAGE, REBUILT, OR HAVE BEEN USED PREVIOUSLY AS A TAXI WILL BE CONSIDERED WITH A REDUCED VALUE OF 35% (thirty-five percent), TAKING
|
||||
AS A BASE THE VALUE OF A SIMILAR NORMAL VEHICLE, THAT IS, ONE THAT HAS NOT BEEN ACQUIRED AS SALVAGE AND ITS PREVIOUS USE HAS NOT BEEN AS A TAXI OR REBUILT. IT WILL BE THE
|
||||
SOLE OBLIGATION AND RESPONSIBILITY OF THE INSURED TO DECLARATE THIS WHEN ACQUIRING THE POLICY.
|
||||
SECTION SPECIFICATION OF RISKS LIMIT OF LIABILITY
|
||||
MATERIAL DAMAGE WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED VEHICLE 0.00 DLLS.
|
||||
1 DEDUCTIBLE: ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND
|
||||
TRAILER
|
||||
STATION WAGONS) AND OTHERS (PICK UPS, VANS, EXCLUDED
|
||||
SUV´s AND MOTOR HOMES). 0.00 DLLS.
|
||||
|
||||
TOTAL THEFT WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED TOWING
|
||||
2 DEDUCTIBLE: ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND 0.00 DLLS.
|
||||
STATION WAGONS) AND OTHERS (PICK UPS, VANS, EXCLUDED
|
||||
SUV´s AND MOTOR HOMES).
|
||||
LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES
|
||||
3 100,000.00 DLLS.
|
||||
|
||||
|
||||
BODILY INJURY LIABILITY PER PER
|
||||
4 PERSON 100,000.00 ACCIDENT 200,000.00 DLLS.
|
||||
|
||||
MEDICAL EXPENSES PER PER
|
||||
5 PERSON 5,000.00 ACCIDENT 25,000.00 DLLS.
|
||||
|
||||
COVERED/EXCLUDED PREMIUM
|
||||
6 A.N.A.'s LEGAL AID
|
||||
COVERED 2.25
|
||||
COVERED/EXCLUDED PREMIUM
|
||||
7 A.N.A.'s ROADSIDE ASSISTANCE
|
||||
COVERED 2.25
|
||||
CATASTROPHIC LIABILITY FOR DEATH OF THIRD PREMIUM
|
||||
8 EXCLUDED
|
||||
PARTIES DLLS. 0.00
|
||||
ELITE OR ELITE PLUS WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED
|
||||
9 PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) 0.00
|
||||
VANDALISM (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) EXCLUDED
|
||||
|
||||
|
||||
ISSUED ONLINE`);
|
||||
|
||||
const ANA_LICENCIA = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV
|
||||
LUIS CABRERA #2033 INT. 201, Col.4 ZONA URBANA RIO TIJUANA
|
||||
C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA
|
||||
www.anaseguros.com.mx
|
||||
DRIVER´S POLICY FOR AUTOMOBILE
|
||||
ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO
|
||||
U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9
|
||||
SPECIAL POLICY FOR TOURISTS
|
||||
TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266
|
||||
WHATSAPP + 52-55-80-50-3633 No. 700489616
|
||||
ISSUED BY: DATE ISSUED & TIME TERM OF INSURANCE
|
||||
JORGE HUMBERTO CUADROS
|
||||
DAYS
|
||||
DAY MONTH YEAR DAY MONTH YEAR TIME
|
||||
BENITO JUAREZ 25 No.50 INT 38 CENTRO 04 08 2026 FROM 06 08 2026 12:01
|
||||
365
|
||||
ROSARITO, BAJA CALIFORNIA 22710 TO 06 08 2027 12:01
|
||||
. 70175
|
||||
DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL
|
||||
- 142.78 30.00 13.82 0.00 186.60
|
||||
|
||||
LICENSE N0017668 EMAIL PWAGONER49@AOL.COM TELEPHONE 3102001538
|
||||
POLICY HOLDER
|
||||
1. NAME : PAMELA DENISE WAGONER Ph.3102001538
|
||||
ADDRESS : 49305 HIGHWAY 74 SPC 10, PALM DESERT, CA, 92260,
|
||||
DRIVER LICENSE : N0017668
|
||||
2. NAME :
|
||||
ADDRESS :
|
||||
DRIVER LICENSE :
|
||||
NONE
|
||||
3. NAME :
|
||||
ADDRESS :
|
||||
DRIVER LICENSE :
|
||||
NONE
|
||||
4. NAME :
|
||||
ADDRESS :
|
||||
DRIVER LICENSE : NONE
|
||||
5. NAME :
|
||||
ADDRESS :
|
||||
DRIVER LICENSE : NONE
|
||||
SPECIFICATION OF RISKS SUM INSURED PREMIUM
|
||||
LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES 100,000.00 usd. 18.70 usd.
|
||||
BODILY INJURY LIABILITY ( EXCLUDING OCCUPANTS OF THE VEHICLE ) 100,000.00 usd. Per Person
|
||||
54.27 usd.
|
||||
200,000.00 usd. Per Accident
|
||||
CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES 0.00 usd. 0.00 usd.
|
||||
|
||||
MEDICAL EXPENSES 4,000.00 usd. Per Person
|
||||
9.81 usd.
|
||||
20,000.00 usd. Per Accident
|
||||
COVERED/EXCLUDED PREMIUM
|
||||
LEGAL AID
|
||||
COVERED 30.00 usd.
|
||||
COVERED/EXCLUDED PREMIUM
|
||||
AUTOMOBILE ASSISTANCE
|
||||
COVERED 30.00 usd.
|
||||
|
||||
The following risks are excluded Collision, overtuning and glass breakage, fire, total theft and natural disasters, partial theft and vandalism.`);
|
||||
|
||||
|
||||
describe("detectPolicyProvider / ANA", () => {
|
||||
it("claims ANA from the letterhead", () => {
|
||||
expect(
|
||||
detectPolicyProvider("A.N.A. COMPAÑIA DE SEGUROS SA DE CV\nwww.anaseguros.com.mx"),
|
||||
).toBe("ANA");
|
||||
});
|
||||
|
||||
it("does not let GMX's layout rules claim an ANA page", () => {
|
||||
// Both books print "MATERIAL DAMAGE"-ish headings; the brand pass runs
|
||||
// before any layout rule precisely so this can't go the other way.
|
||||
expect(detectPolicyProvider(ANA_AUTO_AMPLIA.text)).toBe("ANA");
|
||||
expect(detectPolicyProvider(ANA_LICENCIA.text)).toBe("ANA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePolicy / ANA automobile", () => {
|
||||
const p = parsePolicy(ANA_AUTO_AMPLIA);
|
||||
|
||||
it("reads the header band", () => {
|
||||
expect(p.provider).toBe("ANA");
|
||||
expect(p.policyNumber).toBe("700489651");
|
||||
expect(p.insuredName).toBe("RAY DEAN II AND SUSAN ROCKHOLD");
|
||||
expect(p.agentName).toBe("JORGE HUMBERTO CUADROS");
|
||||
expect(p.legalAddress).toBe("10308 DONNA AVE, NORTHRIDGE, CA 91326");
|
||||
expect(p.zip).toBe("91326");
|
||||
expect(p.currency).toBe("USD");
|
||||
expect(p.premiumPayment).toBe("IMMEDIATE");
|
||||
});
|
||||
|
||||
it("reads DD MM YYYY out of the three date column cells", () => {
|
||||
expect(p.policyDate?.toISOString().slice(0, 10)).toBe("2026-08-04");
|
||||
expect(p.policyFrom?.toISOString().slice(0, 10)).toBe("2026-08-07");
|
||||
expect(p.policyTo?.toISOString().slice(0, 10)).toBe("2027-08-07");
|
||||
});
|
||||
|
||||
it("maps the six money cells positionally, not by finding six amounts", () => {
|
||||
// DISCOUNT prints as a bare "-" here. A "take the amounts in order"
|
||||
// reading would shift every value one column left.
|
||||
expect(p.netPremium).toBe(298.61);
|
||||
expect(p.policyFee).toBe(30);
|
||||
expect(p.total).toBe(354.9);
|
||||
expect(p.notes.join(" | ")).toMatch(/impuesto: 26\.29/);
|
||||
});
|
||||
|
||||
it("reads the vehicle by token role, not by column", () => {
|
||||
expect(p.vehicles).toHaveLength(1);
|
||||
expect(p.vehicles[0]).toEqual({
|
||||
item: "VEHICLE",
|
||||
modelYear: "2017",
|
||||
make: "CHRYSLER",
|
||||
bodyType: "PACIFICA",
|
||||
vinNumber: "2C4RC1DG7HR654698",
|
||||
licensePlate: "8BPX206",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a two-word BODY cell without losing the VIN", () => {
|
||||
// "GENESIS SEDAN" is two tokens where "PACIFICA" is one — the VIN shape
|
||||
// is the anchor, not the token count.
|
||||
const v = parsePolicy(ANA_AUTO_RC_DIAS).vehicles[0];
|
||||
expect(v.make).toBe("FORD");
|
||||
expect(v.vinNumber).toBe("1FBAX2CG3NKA69091");
|
||||
expect(v.licensePlate).toBe("EC46T99");
|
||||
});
|
||||
|
||||
it("skips the empty TRAILER and TOWING slots", () => {
|
||||
// Both print a "." per cell rather than being absent.
|
||||
expect(p.vehicles.map((v) => v.item)).toEqual(["VEHICLE"]);
|
||||
});
|
||||
|
||||
it("records the insured as a named driver with their licence", () => {
|
||||
expect(p.drivers).toHaveLength(1);
|
||||
expect(p.drivers[0].fullName).toBe("RAY DEAN II AND SUSAN ROCKHOLD");
|
||||
expect(p.drivers[0].licenseNumber).toBe("P0066762");
|
||||
expect(p.drivers[0].email).toBe("PROLABSALE@AOL.COM");
|
||||
});
|
||||
|
||||
it("does not read the agent's own street number as the policy number", () => {
|
||||
// "BENITO JUAREZ 25 No.50 INT 38" sits three lines above the No. cell.
|
||||
expect(p.policyNumber).not.toBe("50");
|
||||
expect(p.notes.join(" | ")).not.toMatch(/formas/);
|
||||
});
|
||||
|
||||
it("reads the agent clave without picking up their postal code", () => {
|
||||
// "ROSARITO, BAJA CALIFORNIA 22710" is five digits in the same band.
|
||||
expect(p.notes.join(" | ")).toMatch(/clave de agente: 70175/);
|
||||
expect(p.notes.join(" | ")).not.toMatch(/22710/);
|
||||
});
|
||||
|
||||
it("labels the declared values by their printed item slot", () => {
|
||||
const c = byRisk(p);
|
||||
expect(c["MATERIAL DAMAGE — VEHICLE"]?.insuredAmount).toBe(8000);
|
||||
expect(c["MATERIAL DAMAGE — TRAILER"]?.insuredAmount).toBe(0);
|
||||
expect(c["TOTAL THEFT — TOWING"]?.insuredAmount).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the deductible sentence out of the value columns", () => {
|
||||
const c = byRisk(p);
|
||||
expect(c["MATERIAL DAMAGE — VEHICLE"]?.deductible).toBe(
|
||||
"WITH MINIMUM OF $500.00 ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND " +
|
||||
"STATION WAGONS) AND $500.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND " +
|
||||
"MOTOR HOMES).",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mistake the $500.00 inside the deductible for a sum insured", () => {
|
||||
// It is the one amount in the block not suffixed "DLLS.".
|
||||
const amounts = p.coverages.map((c) => c.insuredAmount);
|
||||
expect(amounts).not.toContain(500);
|
||||
});
|
||||
|
||||
it("splits the per-person and per-accident limits", () => {
|
||||
const c = byRisk(p);
|
||||
expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.insuredAmount).toBe(100000);
|
||||
expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.insuredAmount).toBe(200000);
|
||||
expect(c["MEDICAL EXPENSES — POR PERSONA"]?.insuredAmount).toBe(5000);
|
||||
expect(c["MEDICAL EXPENSES — POR EVENTO"]?.insuredAmount).toBe(25000);
|
||||
});
|
||||
|
||||
it("records an add-on's figure as a premium, never as a sum insured", () => {
|
||||
// $40 is what legal aid COST. As `insuredAmount` it would read on the
|
||||
// review screen as a $40 liability limit.
|
||||
const c = byRisk(p);
|
||||
expect(c["LEGAL AID"]?.premium).toBe(40);
|
||||
expect(c["LEGAL AID"]?.insuredAmount).toBeNull();
|
||||
expect(c["ROADSIDE ASSISTANCE"]?.premium).toBe(40);
|
||||
});
|
||||
|
||||
it("unpacks section 9's parenthesised limit and deductible", () => {
|
||||
const c = byRisk(p);
|
||||
const theft = c["ELITE / ELITE PLUS — PARTIAL THEFT: EXCLUDED"];
|
||||
expect(theft?.insuredAmount).toBe(0);
|
||||
expect(theft?.deductible).toBe("0.00 DLLS. POR EVENTO");
|
||||
expect(c["ELITE / ELITE PLUS — VANDALISM: EXCLUDED"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("emits each coverage once even though the PDF prints the face twice", () => {
|
||||
// The real upload is ORIGINAL + AGENT COPY + receipt + three travel
|
||||
// cards, all concatenated into one string before parsing.
|
||||
const doubled = page(ANA_AUTO_AMPLIA.text + "\n\n" + ANA_AUTO_AMPLIA.text);
|
||||
expect(parsePolicy(doubled).coverages).toHaveLength(p.coverages.length);
|
||||
expect(parsePolicy(doubled).vehicles).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type, as a name for confirm to resolve", () => {
|
||||
it("names ANA's two faces after the legacy tables they belong to", () => {
|
||||
expect(parsePolicy(ANA_AUTO_AMPLIA).policyTypeName).toBe("AUTO");
|
||||
expect(parsePolicy(ANA_AUTO_RC_DIAS).policyTypeName).toBe("AUTO");
|
||||
expect(parsePolicy(ANA_LICENCIA).policyTypeName).toBe("LICENCIAS");
|
||||
});
|
||||
|
||||
it("emits a NAME, never an id — the parser must not need a database", () => {
|
||||
// Anything id-shaped here would mean the parser had reached for the DB.
|
||||
for (const p of [ANA_AUTO_AMPLIA, ANA_AUTO_RC_DIAS, ANA_LICENCIA]) {
|
||||
expect(parsePolicy(p).policyTypeName).toMatch(/^[A-Z_]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the type unnamed when no parser claimed the page", () => {
|
||||
expect(parsePolicy(page("a laundry receipt")).policyTypeName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePolicy / ANA responsabilidad civil por días", () => {
|
||||
const p = parsePolicy(ANA_AUTO_RC_DIAS);
|
||||
|
||||
it("reads a by-the-day term rather than defaulting to a year", () => {
|
||||
// Left at the schema's 365 default this weekend policy would sit in the
|
||||
// renewals window a year out.
|
||||
expect(p.policyFrom?.toISOString().slice(0, 10)).toBe("2026-07-23");
|
||||
expect(p.policyTo?.toISOString().slice(0, 10)).toBe("2026-07-26");
|
||||
expect(p.coveragePeriodDays).toBe(3);
|
||||
});
|
||||
|
||||
it("reads the clave when the agent's phone occupies the left cell", () => {
|
||||
// The by-the-day products print "(661) 612 12 55" ahead of the clave, so
|
||||
// it is no longer the first thing on its line.
|
||||
expect(p.notes.join(" | ")).toMatch(/clave de agente: 70175/);
|
||||
});
|
||||
|
||||
it("marks the excluded sections as excluded, not as insured for zero", () => {
|
||||
const risks = p.coverages.map((c) => c.risk);
|
||||
expect(risks).toContain("MATERIAL DAMAGE — VEHICLE: EXCLUDED");
|
||||
expect(risks).toContain("TOTAL THEFT — TOWING: EXCLUDED");
|
||||
// The liability sections are what this product actually sells, and they
|
||||
// are NOT excluded.
|
||||
expect(risks).toContain("LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePolicy / ANA driver's policy (licencia)", () => {
|
||||
const p = parsePolicy(ANA_LICENCIA);
|
||||
|
||||
it("reads the holder off the numbered POLICY HOLDER list", () => {
|
||||
expect(p.policyNumber).toBe("700489616");
|
||||
expect(p.insuredName).toBe("PAMELA DENISE WAGONER");
|
||||
expect(p.legalAddress).toBe("49305 HIGHWAY 74 SPC 10, PALM DESERT, CA, 92260");
|
||||
expect(p.zip).toBe("92260");
|
||||
});
|
||||
|
||||
it("lists one driver, not one per printed copy of the page", () => {
|
||||
// The face renders three times in the real PDF; an unbounded walk
|
||||
// returns the same person three times, which reads as a three-driver
|
||||
// policy rather than as a parse bug.
|
||||
const tripled = page([ANA_LICENCIA.text, ANA_LICENCIA.text, ANA_LICENCIA.text].join("\n\n"));
|
||||
expect(p.drivers).toHaveLength(1);
|
||||
expect(parsePolicy(tripled).drivers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops the four empty driver slots", () => {
|
||||
// Slots 2-5 print an empty NAME and a bare "NONE" licence.
|
||||
expect(p.drivers.map((d) => d.fullName)).toEqual(["PAMELA DENISE WAGONER"]);
|
||||
expect(p.drivers[0].licenseNumber).toBe("N0017668");
|
||||
expect(p.drivers[0].phone).toBe("3102001538");
|
||||
});
|
||||
|
||||
it("insures no vehicle", () => {
|
||||
expect(p.vehicles).toEqual([]);
|
||||
expect(p.notes.join(" | ")).toMatch(/no ampara un veh[íi]culo determinado/);
|
||||
});
|
||||
|
||||
it("separates the SUM INSURED and PREMIUM columns by position", () => {
|
||||
// Both columns print the same shape ("100,000.00 usd." / "18.70 usd.")
|
||||
// and neither is labelled per row — only the offset tells them apart.
|
||||
const c = byRisk(p);
|
||||
const pd = c["LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES"];
|
||||
expect(pd?.insuredAmount).toBe(100000);
|
||||
expect(pd?.premium).toBe(18.7);
|
||||
});
|
||||
|
||||
it("reads the trailing Per Person / Per Accident labels on this layout", () => {
|
||||
// They FOLLOW their amount here and PRECEDE it on the automobile face.
|
||||
const c = byRisk(p);
|
||||
expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.insuredAmount).toBe(100000);
|
||||
expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.insuredAmount).toBe(200000);
|
||||
expect(c["MEDICAL EXPENSES — POR PERSONA"]?.insuredAmount).toBe(4000);
|
||||
expect(c["MEDICAL EXPENSES — POR EVENTO"]?.insuredAmount).toBe(20000);
|
||||
});
|
||||
|
||||
it("charges a section's premium once, not once per limit", () => {
|
||||
const c = byRisk(p);
|
||||
expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.premium).toBe(54.27);
|
||||
expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.premium).toBeNull();
|
||||
});
|
||||
|
||||
it("handles the section order this layout uses", () => {
|
||||
// CATASTROPHIC LIABILITY prints ABOVE MEDICAL EXPENSES here and below it
|
||||
// on the automobile face; blocks are keyed by where the labels land.
|
||||
const c = byRisk(p);
|
||||
expect(c["CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES"]?.insuredAmount).toBe(0);
|
||||
expect(c["LEGAL AID"]?.premium).toBe(30);
|
||||
expect(c["ROADSIDE ASSISTANCE"]?.premium).toBe(30);
|
||||
});
|
||||
|
||||
it("carries the excluded-risk sentence that defines the product", () => {
|
||||
expect(p.notes.join(" | ")).toMatch(/riesgos excluidos: Collision, overtuning/);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,13 @@ import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
@@ -19,6 +22,11 @@ export class ConfirmPolicyDocumentDto {
|
||||
|
||||
/** Required when creating a new Policy; ignored if `policyId` is set. */
|
||||
@IsOptional() @IsString() customerId?: string;
|
||||
/** Reviewer's explicit lookup picks. Both beat the parsed name; omitted,
|
||||
* the service resolves `policy_types` / `insurance_providers` by name and
|
||||
* leaves the FK null when there is no such row. */
|
||||
@IsOptional() @IsString() policyTypeId?: string;
|
||||
@IsOptional() @IsString() insuranceProviderId?: string;
|
||||
/** Set when the document matched an existing Policy. */
|
||||
@IsOptional() @IsString() policyId?: string;
|
||||
|
||||
@@ -37,6 +45,9 @@ export class ConfirmPolicyDocumentDto {
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsString() premiumPayment?: string;
|
||||
/** Printed term in days. Omitted leaves the parsed value (or the schema's
|
||||
* 365 default) in place; ANA sells 3- and 4-day tourist policies. */
|
||||
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
|
||||
/** Coverages parsed off the PDF, passed through verbatim to Policy.coveragesJson. */
|
||||
@IsOptional() @IsObject() coveragesJson?: unknown;
|
||||
|
||||
@@ -70,6 +81,7 @@ export class ReviewPolicyDocumentDto {
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsString() premiumPayment?: string;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
|
||||
@IsOptional() @IsObject() coveragesJson?: unknown;
|
||||
|
||||
/** Set by the reviewer when the document matched an existing Policy. */
|
||||
|
||||
@@ -10,7 +10,7 @@ import { PrismaService } from "../prisma/prisma.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import type { UploadedFileLike } from "../storage/upload-file";
|
||||
import { OCR_PROVIDER, type OcrPage, type OcrProvider } from "../statements/ocr/ocr.provider";
|
||||
import { parsePolicy } from "./parsers/policy-parser";
|
||||
import { parsePolicy, type ParsedDriver, type ParsedVehicle } from "./parsers/policy-parser";
|
||||
import { PolicyMatcherService } from "./policy-matcher.service";
|
||||
import type {
|
||||
ConfirmPolicyBatchDto,
|
||||
@@ -69,8 +69,11 @@ export class PolicyOcrService {
|
||||
);
|
||||
}
|
||||
|
||||
// The provider is not asked of the uploader and not assumed: `process`
|
||||
// sets it from what the parsers actually claimed, so the batch label can
|
||||
// never contradict its own documents. Until then it says so.
|
||||
const batch = await this.prisma.policyOcrBatch.create({
|
||||
data: { provider: "GMX", uploadedById, label, fileCount: files.length },
|
||||
data: { provider: "por detectar", uploadedById, label, fileCount: files.length },
|
||||
});
|
||||
|
||||
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
||||
@@ -112,6 +115,7 @@ export class PolicyOcrService {
|
||||
|
||||
let fileOrdinal = 0;
|
||||
let globalPageOrdinal = 0;
|
||||
const providersSeen = new Set<string>();
|
||||
for (const file of files) {
|
||||
fileOrdinal += 1;
|
||||
const sourceKey = `policy-ocr/${batchId}/source-${fileOrdinal}.pdf`;
|
||||
@@ -155,6 +159,7 @@ export class PolicyOcrService {
|
||||
if (parsed.provider === "") {
|
||||
throw new Error("no se reconoció el proveedor");
|
||||
}
|
||||
providersSeen.add(parsed.provider);
|
||||
const match = await this.matcher.match(parsed);
|
||||
const notes = [...parsed.notes, match.note].filter(Boolean);
|
||||
// Confident when exactly one Policy carries the printed number —
|
||||
@@ -193,12 +198,20 @@ export class PolicyOcrService {
|
||||
? (parsed.coverages as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
extractedPremiumPayment: parsed.premiumPayment,
|
||||
extractedCoveragePeriodDays: parsed.coveragePeriodDays,
|
||||
extractedVehiclesJson: parsed.vehicles.length
|
||||
? (parsed.vehicles as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
extractedDriversJson: parsed.drivers.length
|
||||
? (parsed.drivers as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
extractedPolicyTypeName: parsed.policyTypeName,
|
||||
matchedPolicyId: match.policyId,
|
||||
matchedCustomerId: match.customerId,
|
||||
matchCandidates: match.candidates.length
|
||||
? (match.candidates as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
matchNote: notes.join("; ").slice(0, 190),
|
||||
matchNote: notes.join("; "),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -211,7 +224,7 @@ export class PolicyOcrService {
|
||||
pageNumber: fileOrdinal,
|
||||
storageKey: sourceKey,
|
||||
status: "OCR_FAILED",
|
||||
matchNote: (err as Error).message.slice(0, 190),
|
||||
matchNote: (err as Error).message,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -219,7 +232,13 @@ export class PolicyOcrService {
|
||||
|
||||
await this.prisma.policyOcrBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: "READY_FOR_REVIEW" },
|
||||
data: {
|
||||
status: "READY_FOR_REVIEW",
|
||||
// Whatever the parsers claimed. A mixed upload is labelled as mixed
|
||||
// rather than as whichever provider happened to come first — the
|
||||
// review header is the only place staff see what they dropped in.
|
||||
provider: [...providersSeen].sort().join(" + ") || "desconocido",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -349,6 +368,7 @@ export class PolicyOcrService {
|
||||
? (dto.coveragesJson as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
extractedPremiumPayment: dto.premiumPayment ?? undefined,
|
||||
extractedCoveragePeriodDays: dto.coveragePeriodDays ?? undefined,
|
||||
matchedPolicyId,
|
||||
matchedCustomerId,
|
||||
status: dto.forceConfirm ? "CONFIRMED" : "MATCHED",
|
||||
@@ -447,14 +467,18 @@ export class PolicyOcrService {
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Resolve target Policy (create or update). Field selection: every
|
||||
// 1. Resolve the lookup rows the parser can only name. The reviewer's
|
||||
// explicit pick always wins; the parsed name is the fallback.
|
||||
const lookups = await this.resolveLookups(item, doc);
|
||||
|
||||
// 2. Resolve target Policy (create or update). Field selection: every
|
||||
// non-null `extracted*` on the doc (post-review) is written. Null is
|
||||
// preserved — never overwrite an existing Policy's `netPremium` with
|
||||
// null because the certificate page didn't carry one.
|
||||
let policyId = item.policyId ?? null;
|
||||
|
||||
if (policyId) {
|
||||
const updateData = buildPolicyUpdateFromDoc(item, doc);
|
||||
const updateData = buildPolicyUpdateFromDoc(item, doc, lookups);
|
||||
await this.prisma.policy.update({
|
||||
where: { id: policyId },
|
||||
data: updateData,
|
||||
@@ -467,21 +491,25 @@ export class PolicyOcrService {
|
||||
`Documento página ${doc.pageNumber}: falta número de póliza.`,
|
||||
);
|
||||
}
|
||||
const createData = buildPolicyCreateFromDoc(item, doc, item.customerId!);
|
||||
const createData = buildPolicyCreateFromDoc(item, doc, item.customerId!, lookups);
|
||||
const created = await this.prisma.policy.create({
|
||||
data: createData,
|
||||
});
|
||||
policyId = created.id;
|
||||
}
|
||||
|
||||
// 2. Attach the source PDF as a PolicyDocument. `doc.storageKey`
|
||||
// 3. Vehicles and named drivers, for the providers whose face carries
|
||||
// them (ANA's automobile and driver's policies; never GMX Hogar).
|
||||
await this.applyVehiclesAndDrivers(doc, policyId);
|
||||
|
||||
// 4. Attach the source PDF as a PolicyDocument. `doc.storageKey`
|
||||
// already points at the exact upload (`policy-ocr/{batchId}/source-N.pdf`)
|
||||
// so the attach is just a stream copy into the policy's namespace —
|
||||
// the previous per-page "which file did this page come from" walk is
|
||||
// gone because one PDF = one doc now.
|
||||
await this.attachSourcePdf(doc.storageKey, policyId);
|
||||
await this.attachSourcePdf(doc.storageKey, policyId, doc.provider);
|
||||
|
||||
// 3. Optionally post the premium to the ledger. Only when staff
|
||||
// 5. Optionally post the premium to the ledger. Only when staff
|
||||
// explicitly asked (`postPremium` true) and netPremium parses — without
|
||||
// that gate a missing premium would silently book $0.
|
||||
let postedTransactionId: string | null = null;
|
||||
@@ -539,13 +567,149 @@ export class PolicyOcrService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the two things the parser can only NAME into foreign keys.
|
||||
*
|
||||
* The parser is a pure function over text and never touches the database,
|
||||
* so it emits `policyTypeName` ("AUTO") and `provider` ("ANA"). Resolving
|
||||
* them here keeps that boundary and means a renamed lookup row is a data
|
||||
* change rather than a parser change.
|
||||
*
|
||||
* **Resolve, never create.** A missing `policy_types` row is a signal that
|
||||
* a human deleted it (that is exactly how M_EMPR disappeared), and silently
|
||||
* recreating it would undo that decision with no record. The field stays
|
||||
* null and the reviewer can add the row through the lookups screen.
|
||||
*
|
||||
* An explicit pick from the reviewer always beats the parsed name.
|
||||
*/
|
||||
private async resolveLookups(
|
||||
item: ConfirmPolicyDocumentDto,
|
||||
doc: { extractedPolicyTypeName: string | null; provider: string | null },
|
||||
): Promise<{ policyTypeId?: string; insuranceProviderId?: string }> {
|
||||
const out: { policyTypeId?: string; insuranceProviderId?: string } = {};
|
||||
|
||||
if (item.policyTypeId) {
|
||||
out.policyTypeId = item.policyTypeId;
|
||||
} else if (doc.extractedPolicyTypeName) {
|
||||
const row = await this.prisma.policyType.findUnique({
|
||||
where: { name: doc.extractedPolicyTypeName },
|
||||
select: { id: true },
|
||||
});
|
||||
if (row) out.policyTypeId = row.id;
|
||||
}
|
||||
|
||||
if (item.insuranceProviderId) {
|
||||
out.insuranceProviderId = item.insuranceProviderId;
|
||||
} else if (doc.provider) {
|
||||
const name = PROVIDER_ROW_NAME[doc.provider] ?? doc.provider;
|
||||
const row = await this.prisma.insuranceProvider.findFirst({
|
||||
where: { name },
|
||||
select: { id: true },
|
||||
});
|
||||
if (row) out.insuranceProviderId = row.id;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the parsed `Vehicle` and `InsuredDriver` rows onto the policy.
|
||||
*
|
||||
* Both inserts are skipped when an equivalent row is already on the policy.
|
||||
* The reason is `confirmBatch` applying to an EXISTING policy: the office
|
||||
* uploads a renewal for a car already on file, and a blind insert would
|
||||
* leave the customer with the same VIN listed twice with no way to tell
|
||||
* which row the renewal belongs to. Matching is on the identifier the
|
||||
* document actually prints — the VIN for a vehicle (falling back to the
|
||||
* plate, since ANA's TRAILER/TOWING slots have no VIN), the licence number
|
||||
* for a driver (falling back to the name).
|
||||
*
|
||||
* Nothing is ever updated or deleted here. A vehicle whose plate changed
|
||||
* lands as a second row for a human to reconcile, which is the safe half
|
||||
* of the mistake: an over-write would destroy the only record of what was
|
||||
* insured last term.
|
||||
*/
|
||||
private async applyVehiclesAndDrivers(
|
||||
doc: { extractedVehiclesJson: Prisma.JsonValue | null; extractedDriversJson: Prisma.JsonValue | null },
|
||||
policyId: string,
|
||||
): Promise<void> {
|
||||
const vehicles = asArray<ParsedVehicle>(doc.extractedVehiclesJson);
|
||||
const drivers = asArray<ParsedDriver>(doc.extractedDriversJson);
|
||||
if (vehicles.length === 0 && drivers.length === 0) return;
|
||||
|
||||
const policy = await this.prisma.policy.findUnique({
|
||||
where: { id: policyId },
|
||||
select: { customerId: true },
|
||||
});
|
||||
if (!policy) return;
|
||||
|
||||
if (vehicles.length) {
|
||||
const existing = await this.prisma.vehicle.findMany({
|
||||
where: { policyId },
|
||||
select: { vinNumber: true, licensePlate: true },
|
||||
});
|
||||
const seen = new Set(
|
||||
existing.flatMap((v) =>
|
||||
[v.vinNumber, v.licensePlate].filter((k): k is string => !!k).map(norm),
|
||||
),
|
||||
);
|
||||
for (const v of vehicles) {
|
||||
const key = norm(v.vinNumber ?? v.licensePlate ?? "");
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
await this.prisma.vehicle.create({
|
||||
data: {
|
||||
policyId,
|
||||
customerId: policy.customerId,
|
||||
make: v.make,
|
||||
// ANA prints one BODY cell, not separate model/body columns, so
|
||||
// it lands on `bodyType`; `model` stays null rather than being
|
||||
// guessed out of the same string.
|
||||
bodyType: v.bodyType,
|
||||
modelYear: v.modelYear,
|
||||
vinNumber: v.vinNumber,
|
||||
licensePlate: v.licensePlate,
|
||||
// "VEHICLE" / "TRAILER" / "TOWING" — the printed slot, which is
|
||||
// the difference between the insured car and the trailer behind
|
||||
// it and has no column of its own.
|
||||
notes: v.item && v.item !== "VEHICLE" ? v.item : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (drivers.length) {
|
||||
const existing = await this.prisma.insuredDriver.findMany({
|
||||
where: { policyId },
|
||||
select: { licenseNumber: true, fullName: true },
|
||||
});
|
||||
const seen = new Set(
|
||||
existing.flatMap((d) =>
|
||||
[d.licenseNumber, d.fullName].filter((k): k is string => !!k).map(norm),
|
||||
),
|
||||
);
|
||||
for (const d of drivers) {
|
||||
const key = norm(d.licenseNumber ?? d.fullName ?? "");
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
await this.prisma.insuredDriver.create({
|
||||
data: { policyId, fullName: d.fullName, licenseNumber: d.licenseNumber },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the source PDF (`sourceKey`, set by `process` on the doc row)
|
||||
* into the policy's storage namespace and create a `PolicyDocument`
|
||||
* pointer. Trivial now that the doc row holds the exact source key —
|
||||
* the old per-page "which file did this page come from" walk is gone.
|
||||
*/
|
||||
private async attachSourcePdf(sourceKey: string, policyId: string): Promise<void> {
|
||||
private async attachSourcePdf(
|
||||
sourceKey: string,
|
||||
policyId: string,
|
||||
provider: string | null,
|
||||
): Promise<void> {
|
||||
const got = await this.storage.getStream(sourceKey);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of got.stream) chunks.push(c as Buffer);
|
||||
@@ -556,7 +720,10 @@ export class PolicyOcrService {
|
||||
await this.prisma.policyDocument.create({
|
||||
data: {
|
||||
policyId,
|
||||
documentType: "GMX_POLICY",
|
||||
// Named after whichever parser claimed the page. Was hardcoded
|
||||
// `GMX_POLICY`, which mislabelled every ANA upload as a GMX
|
||||
// document in the policy's file list.
|
||||
documentType: `${provider ?? "OCR"}_POLICY`,
|
||||
storageKey: newKey,
|
||||
},
|
||||
});
|
||||
@@ -588,6 +755,27 @@ export class PolicyOcrService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parser's provider code is not the carrier's row name in
|
||||
* `insurance_providers`, and the two namespaces are allowed to differ.
|
||||
*
|
||||
* ANA is the case that forces this: the office's book is filed under
|
||||
* "ANA SEGUROS" (738 policies). A bare "ANA" row also existed with 1 policy
|
||||
* and is merged away by `20260815160000_policy_type_repair`, so an exact-name
|
||||
* lookup on the parser's "ANA" would find nothing at all after that migration.
|
||||
*
|
||||
* Anything not listed resolves by its own name.
|
||||
*/
|
||||
const PROVIDER_ROW_NAME: Record<string, string> = {
|
||||
ANA: "ANA SEGUROS",
|
||||
};
|
||||
|
||||
/** The lookup FKs resolved for one document, absent when unresolvable. */
|
||||
interface ResolvedLookups {
|
||||
policyTypeId?: string;
|
||||
insuranceProviderId?: string;
|
||||
}
|
||||
|
||||
/** Map a (post-review) doc + final confirmed fields onto a `Policy.update`
|
||||
* payload. Every field that is null in both inputs is omitted so we never
|
||||
* write null over a value the Policy already carries (the GMX certificate
|
||||
@@ -611,7 +799,9 @@ function buildPolicyUpdateFromDoc(
|
||||
extractedTotal: Prisma.Decimal | null;
|
||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
extractedCoveragePeriodDays: number | null;
|
||||
},
|
||||
lookups: ResolvedLookups,
|
||||
): Prisma.PolicyUpdateInput {
|
||||
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
|
||||
if (a != null) return new Prisma.Decimal(a);
|
||||
@@ -631,10 +821,23 @@ function buildPolicyUpdateFromDoc(
|
||||
|
||||
return {
|
||||
policyNumber: strOrUndef(item.policyNumber, doc.extractedPolicyNumber),
|
||||
// `connect` rather than a raw id: this is the CHECKED update input. Left
|
||||
// undefined when unresolved, so an existing Policy never loses a type or
|
||||
// carrier it already had because this document could not name one.
|
||||
policyType: lookups.policyTypeId ? { connect: { id: lookups.policyTypeId } } : undefined,
|
||||
insuranceProvider: lookups.insuranceProviderId
|
||||
? { connect: { id: lookups.insuranceProviderId } }
|
||||
: undefined,
|
||||
agentName: strOrUndef(item.agentName, doc.extractedAgentName),
|
||||
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
||||
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
||||
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
|
||||
// Left undefined when the document didn't print a term, so the schema
|
||||
// default (365) stands for GMX. ANA's by-the-day policies DO print one,
|
||||
// and the default would otherwise turn a 4-day tourist policy into an
|
||||
// annual one on the renewals screen.
|
||||
coveragePeriodDays:
|
||||
item.coveragePeriodDays ?? doc.extractedCoveragePeriodDays ?? undefined,
|
||||
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
|
||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||
@@ -683,8 +886,10 @@ function buildPolicyCreateFromDoc(
|
||||
extractedTotal: Prisma.Decimal | null;
|
||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
extractedCoveragePeriodDays: number | null;
|
||||
},
|
||||
customerId: string,
|
||||
lookups: ResolvedLookups,
|
||||
): Prisma.PolicyUncheckedCreateInput {
|
||||
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
|
||||
if (a != null) return new Prisma.Decimal(a);
|
||||
@@ -712,10 +917,18 @@ function buildPolicyCreateFromDoc(
|
||||
return {
|
||||
policyNumber,
|
||||
customerId,
|
||||
policyTypeId: lookups.policyTypeId,
|
||||
insuranceProviderId: lookups.insuranceProviderId,
|
||||
agentName: strOrUndef(item.agentName, doc.extractedAgentName),
|
||||
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
||||
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
||||
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
|
||||
// Left undefined when the document didn't print a term, so the schema
|
||||
// default (365) stands for GMX. ANA's by-the-day policies DO print one,
|
||||
// and the default would otherwise turn a 4-day tourist policy into an
|
||||
// annual one on the renewals screen.
|
||||
coveragePeriodDays:
|
||||
item.coveragePeriodDays ?? doc.extractedCoveragePeriodDays ?? undefined,
|
||||
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
|
||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||
@@ -765,3 +978,17 @@ function strOrUndefDb(a: string | undefined, b: string | null): string | undefin
|
||||
if (b != null && b !== "") return b;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A JSON column the parser wrote as an array, read back as one. Anything
|
||||
* else (null, DbNull, a legacy object shape) is an empty list rather than a
|
||||
* crash — these columns are only ever populated by the parser, so a
|
||||
* surprise shape means old data, not a caller to reject. */
|
||||
function asArray<T>(value: Prisma.JsonValue | null): T[] {
|
||||
return Array.isArray(value) ? (value as unknown as T[]) : [];
|
||||
}
|
||||
|
||||
/** Compare identifiers the way a person would: case- and space-insensitive.
|
||||
* VINs and plates are printed inconsistently ("8BPX206" vs "8BPX 206"). */
|
||||
function norm(s: string): string {
|
||||
return s.replace(/\s+/g, "").toUpperCase();
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export const SETTING_KEYS = {
|
||||
scheduleServicios: "notification.schedule.servicios",
|
||||
/** JSON cadence of the automatic pólizas renewal sweep. */
|
||||
schedulePolizas: "notification.schedule.polizas",
|
||||
/** Whether the NUMid allocator may reuse empty portal ids. */
|
||||
numidRecycleEmpty: "numid.recycleEmpty",
|
||||
} as const;
|
||||
|
||||
/** Where a resolved value came from. Shown in the UI. */
|
||||
@@ -169,6 +171,39 @@ export class SettingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the NUMid allocator may reuse empty portal ids instead of only
|
||||
* issuing new ones.
|
||||
*
|
||||
* Defaults to OFF, and the default is the safety property rather than a
|
||||
* preference: while Access remains the utilities master, every reusable id
|
||||
* still exists in DATGRAL, and a `--sync` migration run reassigns the ref back
|
||||
* to its Access owner (transform_customers.py:327). Recycling before utilities
|
||||
* cuts over therefore hands out ids that quietly stop working. No env rung —
|
||||
* this has never been an environment variable and should be flipped
|
||||
* deliberately, in the UI, by someone who knows the cutover happened.
|
||||
*/
|
||||
async numidRecycleEmpty(): Promise<ResolvedSetting<boolean>> {
|
||||
const row = await this.read(SETTING_KEYS.numidRecycleEmpty);
|
||||
if (row) {
|
||||
return {
|
||||
value: row.value === "true",
|
||||
source: "db",
|
||||
updatedAt: row.updatedAt,
|
||||
updatedById: row.updatedById,
|
||||
};
|
||||
}
|
||||
return { value: false, source: "default", updatedAt: null, updatedById: null };
|
||||
}
|
||||
|
||||
async setNumidRecycleEmpty(
|
||||
enabled: boolean,
|
||||
userId: string,
|
||||
): Promise<ResolvedSetting<boolean>> {
|
||||
await this.write(SETTING_KEYS.numidRecycleEmpty, String(enabled), userId);
|
||||
return this.numidRecycleEmpty();
|
||||
}
|
||||
|
||||
private read(key: string) {
|
||||
return this.prisma.appSetting.findUnique({ where: { key } });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "1.0.15",
|
||||
"version": "1.0.19",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,19 +8,18 @@ export const metadata = {
|
||||
"Plataforma interna unificada de clientes, servicios y seguros.",
|
||||
};
|
||||
|
||||
// The browser talks to the API cross-origin, so it needs the API URL at
|
||||
// runtime. NEXT_PUBLIC_* would bake it at build time (one URL per image); we
|
||||
// want the URL to come from the deploy .env instead. So read it here on the
|
||||
// server per request and inject it as window.__API_ORIGIN__ (see lib/api.ts).
|
||||
// force-dynamic guarantees process.env is read at request time, never baked
|
||||
// into a static prerender.
|
||||
// API_ORIGIN is an OPTIONAL override, read here on the server per request and
|
||||
// injected as window.__API_ORIGIN__ (see lib/api.ts). NEXT_PUBLIC_* would bake
|
||||
// it at build time (one URL per image); reading it here keeps one image usable
|
||||
// anywhere. Left unset — the normal case — this injects the empty string and
|
||||
// lib/api.ts derives the origin from window.location instead, so the app
|
||||
// follows the server when it moves without an env edit. force-dynamic
|
||||
// guarantees process.env is read at request time, never baked into a static
|
||||
// prerender.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
const apiOrigin =
|
||||
process.env.API_ORIGIN ??
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
||||
"http://localhost:3001";
|
||||
const apiOrigin = process.env.API_ORIGIN ?? "";
|
||||
// Same reason as the API origin: read on the server per request so the built
|
||||
// image is not pinned to one build identity in its client bundle.
|
||||
const build = readBuildInfoFromEnv();
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
listOpsJobs,
|
||||
startOpsJob,
|
||||
uploadIngest,
|
||||
verifyReplication,
|
||||
} from "@/lib/api";
|
||||
import type { UploadProgress } from "@/lib/api";
|
||||
import type {
|
||||
@@ -28,7 +29,9 @@ import type {
|
||||
IngestFile,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
GtidDrift,
|
||||
ReplicationStatus,
|
||||
VerifyResult,
|
||||
} from "@/lib/types";
|
||||
|
||||
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
@@ -59,6 +62,7 @@ function Operaciones() {
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [forceFull, setForceFull] = useState(false);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
@@ -159,12 +163,12 @@ function Operaciones() {
|
||||
}
|
||||
}
|
||||
|
||||
async function start(kind: OpsJobKind, file?: string) {
|
||||
async function start(kind: OpsJobKind, file?: string, force?: boolean) {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setStarting(true);
|
||||
try {
|
||||
const job = await startOpsJob(kind, file);
|
||||
const job = await startOpsJob(kind, file, force);
|
||||
setActiveJob(job);
|
||||
setJobs((prev) => (prev ? [job, ...prev] : [job]));
|
||||
} catch (e) {
|
||||
@@ -177,6 +181,9 @@ function Operaciones() {
|
||||
function askConfirm(state: ConfirmState) {
|
||||
setConfirm(state);
|
||||
setConfirmText("");
|
||||
// Always re-armed: ticking "delete native rows" once must not carry into
|
||||
// the next reimport.
|
||||
setForceFull(false);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
}
|
||||
@@ -185,7 +192,7 @@ function Operaciones() {
|
||||
if (!confirm) return;
|
||||
const c = confirm;
|
||||
setConfirm(null);
|
||||
if (c.kind === "REIMPORT") await start("REIMPORT");
|
||||
if (c.kind === "REIMPORT") await start("REIMPORT", undefined, forceFull);
|
||||
else if (c.kind === "SYNC") await start("SYNC");
|
||||
else await start("RESTORE", c.file);
|
||||
}
|
||||
@@ -478,11 +485,24 @@ function Operaciones() {
|
||||
</h2>
|
||||
<p className="inline-form-note">
|
||||
{confirm.kind === "REIMPORT"
|
||||
? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático."
|
||||
? "Esto BORRA todos los datos actuales y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático. Si la base contiene registros que sólo existen en la plataforma (clientes creados aquí, números de portal asignados, pólizas capturadas por OCR, movimientos capturados), la operación se detiene y los enumera sin tocar nada."
|
||||
: confirm.kind === "SYNC"
|
||||
? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran."
|
||||
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
|
||||
</p>
|
||||
{confirm.kind === "REIMPORT" && (
|
||||
<label className="inline-form-note" style={{ display: "block" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceFull}
|
||||
onChange={(e) => setForceFull(e.target.checked)}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
Borrar también los registros que sólo existen en la plataforma
|
||||
(ignorar la verificación). Sólo marque esto si de verdad quiere
|
||||
perderlos.
|
||||
</label>
|
||||
)}
|
||||
<label className="field">
|
||||
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
||||
<input
|
||||
@@ -614,6 +634,9 @@ function OpTile({
|
||||
function ReplicationCard() {
|
||||
const [status, setStatus] = useState<ReplicationStatus | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [verify, setVerify] = useState<VerifyResult | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verifyError, setVerifyError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getReplicationStatus()
|
||||
@@ -624,6 +647,17 @@ function ReplicationCard() {
|
||||
.catch(() => setFailed(true));
|
||||
}, []);
|
||||
|
||||
const runVerify = useCallback(() => {
|
||||
setVerifying(true);
|
||||
setVerifyError(null);
|
||||
verifyReplication()
|
||||
.then(setVerify)
|
||||
.catch((e: unknown) =>
|
||||
setVerifyError(e instanceof Error ? e.message : "No se pudo comparar."),
|
||||
)
|
||||
.finally(() => setVerifying(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 30_000);
|
||||
@@ -687,14 +721,144 @@ function ReplicationCard() {
|
||||
value={status.secondsBehind === null ? "sin dato" : `${status.secondsBehind} s`}
|
||||
/>
|
||||
<KV label="Pendiente de aplicar" value={backlogLabel(status.apply)} />
|
||||
<KV label="Diferencia con el maestro" value={driftLabel(status.drift)} />
|
||||
<KV label="Consultado" value={formatDateTime(status.checkedAt)} />
|
||||
</div>
|
||||
|
||||
<ApplyProgressBar apply={status.apply} />
|
||||
|
||||
{/* Only worth showing when it is not zero, and even then as a note rather
|
||||
than a warning: these are the seed load's own transactions, and they
|
||||
are inert until someone tries to promote this box. */}
|
||||
{status.drift !== null && status.drift.localTransactions > 0 && (
|
||||
<p className="inline-form-note" style={{ marginTop: 12 }}>
|
||||
La réplica tiene {status.drift.localTransactions.toLocaleString("es-MX")} transacciones
|
||||
propias (de la carga inicial). No se propagan y no afectan la lectura; sólo importarían
|
||||
si este servidor pasara a ser maestro.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<VerifyPanel
|
||||
result={verify}
|
||||
running={verifying}
|
||||
error={verifyError}
|
||||
onRun={runVerify}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-by-row comparison against the master, on demand.
|
||||
*
|
||||
* Separate from the polled fields because it costs a full scan of both servers.
|
||||
* It is the only check here that can catch a row changed on the replica by
|
||||
* something other than replication — the GTID and lag figures would both still
|
||||
* read perfectly healthy in that case.
|
||||
*/
|
||||
function VerifyPanel({
|
||||
result,
|
||||
running,
|
||||
error,
|
||||
onRun,
|
||||
}: {
|
||||
result: VerifyResult | null;
|
||||
running: boolean;
|
||||
error: string | null;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginTop: 16, borderTop: "1px solid var(--border)", paddingTop: 12 }}>
|
||||
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||
<span className="inline-form-note" style={{ margin: 0 }}>
|
||||
Compara fila por fila las 8 tablas que lee el sitio de clientes. Recorre ambos
|
||||
servidores por completo, así que tarda.
|
||||
</span>
|
||||
<button className="btn btn-ghost" type="button" onClick={onRun} disabled={running}>
|
||||
{running ? "Comparando…" : "Comparar con el maestro"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.problem && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{result.problem}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !result.problem && (
|
||||
<>
|
||||
<p style={{ marginTop: 12, marginBottom: 8 }}>
|
||||
<span className={`badge ${result.identical ? "badge-positive" : "badge-negative"}`}>
|
||||
{result.identical ? "Idénticas" : "Hay diferencias"}
|
||||
</span>{" "}
|
||||
<span className="inline-form-note">
|
||||
{formatDateTime(result.checkedAt)} · {(result.elapsedMs / 1000).toFixed(1)} s
|
||||
</span>
|
||||
</p>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tabla</th>
|
||||
<th style={{ textAlign: "right" }}>Maestro</th>
|
||||
<th style={{ textAlign: "right" }}>Réplica</th>
|
||||
<th>Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.tables.map((t) => (
|
||||
<tr key={t.table}>
|
||||
<td>{t.table}</td>
|
||||
{/* -1 is the sentinel for "that server did not answer for
|
||||
this table", which is not the same as zero rows. */}
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{t.masterRows < 0 ? "—" : t.masterRows.toLocaleString("es-MX")}
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{t.replicaRows < 0 ? "—" : t.replicaRows.toLocaleString("es-MX")}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${t.matches ? "badge-positive" : "badge-negative"}`}>
|
||||
{t.matches
|
||||
? "igual"
|
||||
: t.masterRows !== t.replicaRows
|
||||
? "difieren en filas"
|
||||
: "difieren en contenido"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactions the master has executed that the replica has not.
|
||||
*
|
||||
* Rendered as its own line rather than folded into the lag figure because the
|
||||
* two disagree in exactly the case that matters: a disconnected I/O thread
|
||||
* reports 0 seconds of lag (no event has arrived to be late) while this number
|
||||
* climbs.
|
||||
*/
|
||||
function driftLabel(drift: GtidDrift | null): string {
|
||||
// Null means the master could not be reached. Saying "al día" here would be a
|
||||
// lie of the worst kind — it is the reading a broken check produces.
|
||||
if (drift === null) return "sin dato";
|
||||
if (drift.missingTransactions === 0) return "al día";
|
||||
return `${drift.missingTransactions.toLocaleString("es-MX")} transacciones atrás`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes the replica has fetched but not yet applied.
|
||||
*
|
||||
|
||||
@@ -648,10 +648,79 @@ function SiniestrosSection({ data }: { data: PolicyDetail }) {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Coberturas */
|
||||
/** The legacy tables carry per-line coverage columns the target schema does
|
||||
* not model; the migration preserved them verbatim in `coveragesJson`. */
|
||||
/**
|
||||
* `coveragesJson` holds two unrelated shapes and the section renders each on
|
||||
* its own terms:
|
||||
*
|
||||
* - **A Spanish-keyed object** — the legacy per-line coverage columns the
|
||||
* target schema does not model, preserved verbatim by the migration. Every
|
||||
* policy imported from Access carries this one.
|
||||
* - **A `ParsedCoverage[]` array** — written by the policy OCR confirm step
|
||||
* (GMX's coverage table, ANA's numbered risk sections).
|
||||
*
|
||||
* Running the object renderer over the array is what used to happen, and it
|
||||
* produced a row per array index labelled "0", "1", "2" with `[object
|
||||
* Object]` as its value — not a crash, so nothing surfaced it.
|
||||
*/
|
||||
interface StoredCoverage {
|
||||
risk?: string;
|
||||
insuredAmount?: number | null;
|
||||
deductible?: string | null;
|
||||
lossParticipation?: string | null;
|
||||
premium?: number | null;
|
||||
}
|
||||
|
||||
function CoberturasSection({ data }: { data: PolicyDetail }) {
|
||||
const entries = Object.entries(data.coveragesJson ?? {}).filter(
|
||||
const raw = data.coveragesJson ?? null;
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
const rows = (raw as StoredCoverage[]).filter((c) => c && c.risk);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="seguros" title="Coberturas" count={rows.length} />
|
||||
<div className="card">
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Riesgo</th>
|
||||
<th className="num">Suma asegurada</th>
|
||||
<th className="num">Prima</th>
|
||||
<th>Deducible</th>
|
||||
<th>Participación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((c, i) => (
|
||||
<tr key={i}>
|
||||
<td>{c.risk}</td>
|
||||
<td className="num">
|
||||
{c.insuredAmount == null
|
||||
? "—"
|
||||
: formatMoney(c.insuredAmount.toString(), data.currency)}
|
||||
</td>
|
||||
<td className="num">
|
||||
{c.premium == null
|
||||
? "—"
|
||||
: formatMoney(c.premium.toString(), data.currency)}
|
||||
</td>
|
||||
<td>{c.deductible ?? "—"}</td>
|
||||
<td>{c.lossParticipation ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||
Coberturas leídas del PDF de la aseguradora.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(raw ?? {}).filter(
|
||||
([, v]) => v !== null && v !== "" && v !== 0,
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AppShell } from "@/components/AppShell";
|
||||
import { PolicyCaptura } from "@/components/PolicyCaptura";
|
||||
|
||||
/**
|
||||
* OCR mode of the policy intake screen. Drops the GMX PDF, walks through
|
||||
* OCR mode of the policy intake screen. Drops the GMX or A.N.A. PDF, walks through
|
||||
* per-page review, confirms. Same wrapper as `/polizas/nuevo` (manual)
|
||||
* with `initialMode="auto"`, so the tab strip is identical and swapping
|
||||
* modes doesn't drop state.
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useCan } from "@/lib/abilities";
|
||||
* two ways in:
|
||||
*
|
||||
* - **manual** — `PolicyForm` keys every field by hand.
|
||||
* - **auto** — `PolicyOcrIntake` uploads a GMX PDF, OCR proposes the
|
||||
* - **auto** — `PolicyOcrIntake` uploads a GMX or A.N.A. PDF, OCR proposes the
|
||||
* policy, a human still confirms.
|
||||
*
|
||||
* Both end at the same place (a `Policy` row on a customer's file) so they
|
||||
@@ -28,7 +28,7 @@ export type PolicyCaptureMode = "manual" | "auto";
|
||||
const MODE_HINT: Record<PolicyCaptureMode, string> = {
|
||||
manual:
|
||||
"Captura cada campo a mano. Use esta opción cuando la póliza llega en papel, en un correo sin PDF legible, o cuando hay que revisar cada dato.",
|
||||
auto: "Suelte el PDF descargado del portal de GMX y el sistema propondrá los campos. Nada se registra sin tu confirmación.",
|
||||
auto: "Suelte el PDF descargado del portal de GMX o de A.N.A. y el sistema propondrá los campos. Nada se registra sin tu confirmación.",
|
||||
};
|
||||
|
||||
export function PolicyCaptura({ initialMode = "manual" }: { initialMode?: PolicyCaptureMode }) {
|
||||
|
||||
@@ -13,9 +13,12 @@ import type { PolicyOcrBatch, PolicyOcrBatchStatus } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Insurance OCR intake — mirror of StatementIntake, scoped to the insurance
|
||||
* side. Today the only provider is GMX; the parser dispatches on a brand
|
||||
* wordmark (`Grupo Mexicano de Seguros` / `gmx.com.mx` / the GMX letterhead)
|
||||
* and a new portal only needs a new BRAND entry plus a parser file.
|
||||
* side. GMX and A.N.A. today; the parser dispatches on a brand wordmark
|
||||
* (`Grupo Mexicano de Seguros` / `gmx.com.mx`, `A.N.A. Compañía de Seguros` /
|
||||
* `anaseguros.com.mx`) and a new portal only needs a new BRAND entry plus a
|
||||
* parser file. The uploader is never asked which provider a file came from —
|
||||
* a batch may mix them, and the pipeline labels the batch from what the
|
||||
* parsers actually claimed.
|
||||
*
|
||||
* Lives inside the `Pólizas` page rather than a top-level route because it
|
||||
* is one mode of one job (staff uploading whatever PDFs the office has on
|
||||
@@ -102,8 +105,8 @@ export function PolicyOcrIntake() {
|
||||
<div className="state-box">Cargando…</div>
|
||||
) : batches.length === 0 ? (
|
||||
<div className="state-box">
|
||||
Todavía no hay lotes de pólizas. Descargue el certificado del portal
|
||||
de GMX y suéltelo arriba.
|
||||
Todavía no hay lotes de pólizas. Descargue la póliza del portal de
|
||||
GMX o de A.N.A. y suéltela arriba.
|
||||
</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
@@ -183,14 +186,14 @@ function UploadCard({ onDone }: { onDone: () => void }) {
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Subir PDFs de pólizas (GMX)
|
||||
Subir PDFs de pólizas (GMX / A.N.A.)
|
||||
</h2>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Referencia (opcional)</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="ej. GMX julio 2026"
|
||||
placeholder="ej. ANA agosto 2026"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -274,6 +274,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
netPremium: doc.extractedNetPremium ?? "",
|
||||
total: doc.extractedTotal ?? "",
|
||||
premiumPayment: doc.extractedPremiumPayment ?? "",
|
||||
coveragePeriodDays: doc.extractedCoveragePeriodDays?.toString() ?? "",
|
||||
postPremium: doc.extractedNetPremium != null && Number(doc.extractedNetPremium) > 0,
|
||||
});
|
||||
const [customerId, setCustomerId] = useState(
|
||||
@@ -311,6 +312,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
netPremium: numOrUndef(v.netPremium),
|
||||
total: numOrUndef(v.total),
|
||||
premiumPayment: trimOrUndef(v.premiumPayment),
|
||||
coveragePeriodDays: numOrUndef(v.coveragePeriodDays),
|
||||
matchedPolicyId: policyId || undefined,
|
||||
matchedCustomerId: !policyId && customerId ? customerId : undefined,
|
||||
forceConfirm: true,
|
||||
@@ -332,6 +334,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
netPremium: reviewInput.netPremium,
|
||||
total: reviewInput.total,
|
||||
premiumPayment: reviewInput.premiumPayment,
|
||||
coveragePeriodDays: reviewInput.coveragePeriodDays,
|
||||
coveragesJson: (doc.extractedCoveragesJson ?? undefined) as
|
||||
| PolicyOcrCoverage[]
|
||||
| undefined,
|
||||
@@ -360,6 +363,13 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
{doc.extractedInsuredName && (
|
||||
<span className="page-sub">· {doc.extractedInsuredName}</span>
|
||||
)}
|
||||
{/* Read-only: the parser names the type, the confirm step resolves it
|
||||
to a policy_types row. Reassigning it is the policy screen's job,
|
||||
where the full picker already lives. */}
|
||||
{doc.extractedPolicyTypeName && (
|
||||
<span className="tag">{doc.extractedPolicyTypeName}</span>
|
||||
)}
|
||||
{doc.provider && <span className="tag">{doc.provider}</span>}
|
||||
</header>
|
||||
|
||||
<div className="doc-detail">
|
||||
@@ -454,6 +464,17 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
onChange={(e) => set("policyDate", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{/* ANA sells 3- and 4-day tourist policies; left blank the
|
||||
póliza keeps the 365-day default. */}
|
||||
<Field label="Días de vigencia">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={1}
|
||||
value={v.coveragePeriodDays}
|
||||
onChange={(e) => set("coveragePeriodDays", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Moneda">
|
||||
<select
|
||||
className="input select"
|
||||
@@ -523,6 +544,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
<tr>
|
||||
<th>Riesgo</th>
|
||||
<th className="num">Suma</th>
|
||||
<th className="num">Prima</th>
|
||||
<th>Deducible</th>
|
||||
<th>Participación</th>
|
||||
</tr>
|
||||
@@ -534,6 +556,14 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
<td className="num">
|
||||
{formatMoney(c.insuredAmount?.toString() ?? null, v.currency)}
|
||||
</td>
|
||||
{/* ANA's add-on sections print what the coverage COST
|
||||
where the others print what it pays. Kept in its own
|
||||
column so the two are never added together. */}
|
||||
<td className="num">
|
||||
{c.premium == null
|
||||
? "—"
|
||||
: formatMoney(c.premium.toString(), v.currency)}
|
||||
</td>
|
||||
<td>{c.deductible ?? "—"}</td>
|
||||
<td>{c.lossParticipation ?? "—"}</td>
|
||||
</tr>
|
||||
@@ -543,6 +573,66 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* Vehicles and named drivers are read-only here: they are written
|
||||
* as their own Vehicle / InsuredDriver rows on confirm, and the
|
||||
* policy screen is where they get edited. Showing them is what
|
||||
* lets a reviewer catch a misread VIN before it is applied.
|
||||
*/}
|
||||
{doc.extractedVehiclesJson && doc.extractedVehiclesJson.length > 0 && (
|
||||
<details>
|
||||
<summary>Unidades ({doc.extractedVehiclesJson.length})</summary>
|
||||
<table className="tx-table" style={{ marginTop: 8 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tipo</th>
|
||||
<th>Año</th>
|
||||
<th>Marca</th>
|
||||
<th>Carrocería</th>
|
||||
<th>Serie</th>
|
||||
<th>Placas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{doc.extractedVehiclesJson.map((veh, i) => (
|
||||
<tr key={i}>
|
||||
<td>{veh.item}</td>
|
||||
<td>{veh.modelYear ?? "—"}</td>
|
||||
<td>{veh.make ?? "—"}</td>
|
||||
<td>{veh.bodyType ?? "—"}</td>
|
||||
<td>{veh.vinNumber ?? "—"}</td>
|
||||
<td>{veh.licensePlate ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{doc.extractedDriversJson && doc.extractedDriversJson.length > 0 && (
|
||||
<details>
|
||||
<summary>Conductores ({doc.extractedDriversJson.length})</summary>
|
||||
<table className="tx-table" style={{ marginTop: 8 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Licencia</th>
|
||||
<th>Teléfono</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{doc.extractedDriversJson.map((d, i) => (
|
||||
<tr key={i}>
|
||||
<td>{d.fullName}</td>
|
||||
<td>{d.licenseNumber ?? "—"}</td>
|
||||
<td>{d.phone ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{candidates.length > 1 && (
|
||||
<Field label="Póliza destino">
|
||||
<select
|
||||
|
||||
+52
-8
@@ -60,6 +60,7 @@ import type {
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
ReplicationStatus,
|
||||
VerifyResult,
|
||||
IngestFile,
|
||||
BackupFile,
|
||||
PropertyDetail,
|
||||
@@ -81,15 +82,26 @@ import type {
|
||||
UserRow,
|
||||
} from "./types";
|
||||
|
||||
// Resolve the API origin at runtime, not build time. In the browser it comes
|
||||
// from window.__API_ORIGIN__, injected server-side by the root layout from the
|
||||
// deploy .env (API_ORIGIN) — so one built image serves any deployment. On the
|
||||
// server (SSR) read process.env directly. NEXT_PUBLIC_API_ORIGIN stays as the
|
||||
// dev/build fallback.
|
||||
// Resolve the API origin at runtime, not build time — so one built image serves
|
||||
// any deployment and the app follows the box when it moves (tailnet today,
|
||||
// 192.168.1.x office LAN later) with no config change.
|
||||
//
|
||||
// In the browser, derive the origin from the page's own location, the way a PHP
|
||||
// app would. An explicit API_ORIGIN (injected as window.__API_ORIGIN__ by the
|
||||
// root layout) still wins when a deployment genuinely splits the two hosts.
|
||||
// On the server (SSR) read process.env directly — a derived origin is
|
||||
// browser-only, and "/api" is not fetchable server-side.
|
||||
function resolveApiOrigin(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
const injected = (window as { __API_ORIGIN__?: string }).__API_ORIGIN__;
|
||||
if (injected) return injected;
|
||||
const { protocol, hostname } = window.location;
|
||||
// Over TLS the API must share the page's origin or the browser blocks the
|
||||
// call as mixed active content. The reverse proxy maps /api to the API.
|
||||
if (protocol === "https:") return "/api";
|
||||
// Plain HTTP: same host, API port. 3001 is the port the API container
|
||||
// publishes everywhere (deploy/galactus/jorgecuadros-app.compose.yml).
|
||||
return `http://${hostname}:3001`;
|
||||
}
|
||||
return (
|
||||
process.env.API_ORIGIN ??
|
||||
@@ -232,6 +244,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
|
||||
@@ -950,6 +976,18 @@ export function getReplicationStatus(): Promise<ReplicationStatus> {
|
||||
return apiFetch<ReplicationStatus>("/ops/replication");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare every customer-visible table against the master, row by row.
|
||||
*
|
||||
* Slow by nature — it is a full scan of both servers — so it is a button, not
|
||||
* part of the poll. Answers the question replication status cannot: GTIDs prove
|
||||
* the replica applied everything the master sent, not that nothing else changed
|
||||
* the rows here.
|
||||
*/
|
||||
export function verifyReplication(): Promise<VerifyResult> {
|
||||
return apiFetch<VerifyResult>("/ops/replication/verify", { method: "POST" });
|
||||
}
|
||||
|
||||
export function listOpsJobs(): Promise<OpsJob[]> {
|
||||
return apiFetch<OpsJob[]>("/ops/jobs");
|
||||
}
|
||||
@@ -959,10 +997,16 @@ export function getOpsJob(id: string): Promise<OpsJob> {
|
||||
}
|
||||
|
||||
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
|
||||
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
|
||||
/** `forceFull` applies to REIMPORT only: proceed even though the rebuild
|
||||
* deletes rows that exist only in the platform. */
|
||||
export function startOpsJob(
|
||||
kind: OpsJobKind,
|
||||
file?: string,
|
||||
forceFull?: boolean,
|
||||
): Promise<OpsJob> {
|
||||
return apiFetch<OpsJob>("/ops/jobs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, file }),
|
||||
body: JSON.stringify({ kind, file, forceFull }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1388,7 +1432,7 @@ export function statementPageUrl(documentId: string): string {
|
||||
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------- Policy OCR (GMX) */
|
||||
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
|
||||
|
||||
export function getPolicyOcrStatus(): Promise<{
|
||||
ocrAvailable: boolean;
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Ability =
|
||||
| "customer:create"
|
||||
| "customer:update"
|
||||
| "customer:delete"
|
||||
| "customer:portal-access"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
@@ -100,10 +101,52 @@ export interface ReplicationStatus {
|
||||
lastSqlError: string | null;
|
||||
sourceHost: string | null;
|
||||
apply: ApplyProgress | null;
|
||||
drift: GtidDrift | null;
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executed-history gap between master and replica, in transactions.
|
||||
*
|
||||
* The only field on the card that is not self-reported by the replica, and the
|
||||
* only one that catches a silently disconnected I/O thread: with no incoming
|
||||
* events, `secondsBehind` reads 0 because there is nothing to measure staleness
|
||||
* against, so a dead link looks perfectly current. This number grows instead.
|
||||
*
|
||||
* Null when the master could not be reached — "unknown" must not render as
|
||||
* "identical".
|
||||
*/
|
||||
export interface GtidDrift {
|
||||
missingTransactions: number;
|
||||
missingGtidSet: string | null;
|
||||
/**
|
||||
* Transactions written on the replica under its own server UUID, which exist
|
||||
* nowhere on the master. Non-zero is expected — restoring the seed dump
|
||||
* executed its statements locally — and harmless while nothing replicates
|
||||
* from this node.
|
||||
*/
|
||||
localTransactions: number;
|
||||
}
|
||||
|
||||
/** One table compared on both sides of the link. */
|
||||
export interface TableFingerprint {
|
||||
table: string;
|
||||
masterRows: number;
|
||||
replicaRows: number;
|
||||
masterChecksum: string;
|
||||
replicaChecksum: string;
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
identical: boolean;
|
||||
tables: TableFingerprint[];
|
||||
problem: string | null;
|
||||
checkedAt: string;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay-log apply progress, in source binlog bytes.
|
||||
*
|
||||
@@ -1362,7 +1405,7 @@ export interface DiscardBatchResult {
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ Policy OCR intake (GMX) */
|
||||
/* ------------------------------------- Policy OCR intake (GMX / ANA) */
|
||||
|
||||
export type PolicyOcrBatchStatus =
|
||||
| "UPLOADED"
|
||||
@@ -1404,6 +1447,27 @@ export interface PolicyOcrCoverage {
|
||||
insuredAmount: number | null;
|
||||
deductible: string | null;
|
||||
lossParticipation: string | null;
|
||||
/** What the coverage COST, where the layout prints it separately from what
|
||||
* it pays out (ANA's add-on sections). GMX never prints one. */
|
||||
premium?: number | null;
|
||||
}
|
||||
|
||||
/** A row of ANA's `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table. */
|
||||
export interface PolicyOcrVehicle {
|
||||
item: string;
|
||||
modelYear: string | null;
|
||||
make: string | null;
|
||||
bodyType: string | null;
|
||||
vinNumber: string | null;
|
||||
licensePlate: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyOcrDriver {
|
||||
fullName: string;
|
||||
licenseNumber: string | null;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyOcrMatchCandidate {
|
||||
@@ -1435,6 +1499,11 @@ export interface PolicyOcrDocument {
|
||||
extractedTotal: string | null;
|
||||
extractedCoveragesJson: PolicyOcrCoverage[] | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
extractedCoveragePeriodDays: number | null;
|
||||
extractedVehiclesJson: PolicyOcrVehicle[] | null;
|
||||
extractedDriversJson: PolicyOcrDriver[] | null;
|
||||
/** `PolicyType.name` the parser read, resolved to an id only at confirm. */
|
||||
extractedPolicyTypeName: string | null;
|
||||
matchedPolicy: {
|
||||
id: string;
|
||||
policyNumber: string | null;
|
||||
@@ -1462,6 +1531,7 @@ export interface PolicyOcrReviewInput {
|
||||
brokerFee?: number;
|
||||
total?: number;
|
||||
premiumPayment?: string;
|
||||
coveragePeriodDays?: number;
|
||||
coveragesJson?: PolicyOcrCoverage[];
|
||||
matchedPolicyId?: string;
|
||||
matchedCustomerId?: string;
|
||||
@@ -1487,7 +1557,11 @@ export interface PolicyOcrConfirmDocument {
|
||||
brokerFee?: number;
|
||||
total?: number;
|
||||
premiumPayment?: string;
|
||||
coveragePeriodDays?: number;
|
||||
coveragesJson?: PolicyOcrCoverage[];
|
||||
/** Explicit lookup picks; both beat the name the parser read. */
|
||||
policyTypeId?: string;
|
||||
insuranceProviderId?: string;
|
||||
postPremium?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@ services:
|
||||
INGEST_DIR: /data/ingest
|
||||
BACKUP_DIR: /data/backups
|
||||
MIGRATION_ENV: prod
|
||||
# The API applies pending Prisma migrations at container start, before
|
||||
# Nest listens, and refuses to start if they fail (docker/api-entrypoint.sh).
|
||||
# Set false ONLY when the schema is being moved by hand — the app will
|
||||
# then boot against whatever schema it finds.
|
||||
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
|
||||
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
|
||||
# application user: --single-transaction needs the global RELOAD privilege
|
||||
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
|
||||
@@ -132,8 +137,12 @@ services:
|
||||
dns_search:
|
||||
- ${TAILNET_SUFFIX:-tail01aa2.ts.net}
|
||||
environment:
|
||||
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
||||
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
||||
# OPTIONAL override of the API URL the browser calls (injected at runtime,
|
||||
# see layout.tsx). Leave it unset: the browser then derives the origin
|
||||
# from the page it loaded — same host on port 3001 over plain HTTP, or
|
||||
# /api behind a TLS-terminating proxy. Set it only when the API really
|
||||
# lives on a different host than the web app.
|
||||
API_ORIGIN: ${API_ORIGIN:-}
|
||||
ports:
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
|
||||
@@ -8,10 +8,21 @@
|
||||
APP_TAG=latest
|
||||
|
||||
# --- Public URLs (what the end user's BROWSER hits) ---------------------------
|
||||
# API_ORIGIN is injected into the web app at runtime and used for browser fetches
|
||||
# + document download links, so it must be browser-reachable (not swarm-internal).
|
||||
# WEB_ORIGIN is the web app's own public origin; the API allows it via CORS.
|
||||
API_ORIGIN=http://192.168.4.212:3001
|
||||
# API_ORIGIN is OPTIONAL and normally left unset. The browser derives the API
|
||||
# origin from the page it loaded (apps/web/src/lib/api.ts): same host on port
|
||||
# 3001 over plain HTTP, or the same-origin /api path when the page is served
|
||||
# over https by a TLS-terminating proxy that maps /api to the API. That is what
|
||||
# lets the same deployment move — tailnet, office LAN, demo domain — untouched.
|
||||
# Set it only when the API genuinely lives on a different host than the web app;
|
||||
# it is used for browser fetches AND document download links, so it must be
|
||||
# browser-reachable (never a swarm-internal name).
|
||||
#API_ORIGIN=http://192.168.4.212:3001
|
||||
#
|
||||
# WEB_ORIGIN is the list of public origins the web app is reached under; the API
|
||||
# allows them via CORS. COMMA-SEPARATED — one deployment is reachable under
|
||||
# several origins (LAN IP, tailnet name, demo domain) and a credentialed fetch
|
||||
# from an origin missing here gets no CORS headers and fails. A same-origin
|
||||
# setup (web + API behind one proxy) never hits CORS at all.
|
||||
WEB_ORIGIN=http://192.168.4.212:3000
|
||||
|
||||
# Published ports on the swarm host.
|
||||
|
||||
@@ -41,6 +41,11 @@ services:
|
||||
INGEST_DIR: /data/ingest
|
||||
BACKUP_DIR: /data/backups
|
||||
MIGRATION_ENV: prod
|
||||
# The API applies pending Prisma migrations at container start, before
|
||||
# Nest listens, and refuses to start if they fail (docker/api-entrypoint.sh).
|
||||
# Set false ONLY when the schema is being moved by hand — the app will
|
||||
# then boot against whatever schema it finds.
|
||||
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
|
||||
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
|
||||
# application user: --single-transaction needs the global RELOAD privilege
|
||||
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
|
||||
@@ -92,8 +97,12 @@ services:
|
||||
labels:
|
||||
io.jorgecuadros.role: "web"
|
||||
environment:
|
||||
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
||||
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
||||
# OPTIONAL override of the API URL the browser calls (injected at runtime,
|
||||
# see layout.tsx). Leave it unset: the browser then derives the origin
|
||||
# from the page it loaded — same host on port 3001 over plain HTTP, or
|
||||
# /api behind a TLS-terminating proxy. Set it only when the API really
|
||||
# lives on a different host than the web app.
|
||||
API_ORIGIN: ${API_ORIGIN:-}
|
||||
ports:
|
||||
- target: 3000
|
||||
published: ${WEB_PORT:-3000}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/bin/sh
|
||||
# Apply pending Prisma migrations, then hand off to the API.
|
||||
#
|
||||
# WHY THE CONTAINER AND NOT THE DEPLOY WORKFLOW
|
||||
#
|
||||
# The workflow still has its own `prisma migrate deploy` step and that is not
|
||||
# redundant: it runs BEFORE the new images are pulled, i.e. while the OLD code
|
||||
# is still serving, which is the order expand/contract migrations are designed
|
||||
# around (see docs/DEPLOY_AND_MIGRATIONS.md). Doing it here as well closes the
|
||||
# gaps that step cannot:
|
||||
#
|
||||
# - The runner has to reach MySQL directly. When it cannot, the deploy is run
|
||||
# with `skip_migrate=true` and the schema silently does not move — the app
|
||||
# then boots against a schema that is one release behind, which surfaces
|
||||
# later as a column-not-found at runtime rather than as a failed deploy.
|
||||
# - A container restarted by `restart: unless-stopped` after a host reboot,
|
||||
# or a stack re-applied by hand in Portainer, never goes through the
|
||||
# workflow at all.
|
||||
#
|
||||
# `migrate deploy` is idempotent, so running it in both places costs one
|
||||
# no-op query on the normal path.
|
||||
#
|
||||
# THE API DOES NOT START IF THE MIGRATION FAILS. That is deliberate: serving
|
||||
# against a schema that does not match the code is worse than being down,
|
||||
# because the failures it produces are partial and silent (a write to a column
|
||||
# that does not exist yet fails for one feature while the rest of the app looks
|
||||
# healthy). The container exits non-zero and Docker's restart policy retries.
|
||||
set -e
|
||||
|
||||
SCHEMA=/repo/packages/database/prisma/schema.prisma
|
||||
|
||||
log() { echo "[entrypoint] $*"; }
|
||||
|
||||
if [ "${RUN_MIGRATIONS:-true}" != "true" ]; then
|
||||
log "RUN_MIGRATIONS=${RUN_MIGRATIONS} — skipping migrations, starting the API"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
if [ -z "${DATABASE_URL}" ]; then
|
||||
log "DATABASE_URL is unset; cannot migrate." >&2
|
||||
log "Set it, or set RUN_MIGRATIONS=false if you migrate out of band." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# pnpm's hoisted linker normally puts the CLI in the root .bin, but the
|
||||
# workspace package keeps its own link too. Accept either rather than pinning
|
||||
# a layout detail of the installer — the Dockerfile asserts at build time that
|
||||
# one of these exists, so a missing CLI breaks the image build, not a deploy.
|
||||
PRISMA=""
|
||||
for candidate in /repo/node_modules/.bin/prisma \
|
||||
/repo/packages/database/node_modules/.bin/prisma; do
|
||||
if [ -x "$candidate" ]; then
|
||||
PRISMA="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$PRISMA" ]; then
|
||||
log "prisma CLI not found in this image; cannot migrate." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Retry ONLY a connection failure (P1001). On a full bring-up the database
|
||||
# container can still be starting, and on galactus the API additionally has to
|
||||
# resolve the host's MagicDNS name — a lookup that is unreliable for the first
|
||||
# moments after a host reboot (see the dns block in the app compose file, and
|
||||
# docs/DEPLOY_AND_MIGRATIONS.md).
|
||||
#
|
||||
# Every other failure exits immediately. Retrying a migration that is actually
|
||||
# broken just delays the same error behind a minute of noise, and P3005 in
|
||||
# particular needs a human.
|
||||
attempt=1
|
||||
max="${MIGRATE_MAX_ATTEMPTS:-20}"
|
||||
delay="${MIGRATE_RETRY_SECONDS:-3}"
|
||||
|
||||
while : ; do
|
||||
log "prisma migrate deploy (attempt ${attempt}/${max})"
|
||||
if output=$("$PRISMA" migrate deploy --schema "$SCHEMA" 2>&1); then
|
||||
printf '%s\n' "$output"
|
||||
log "migrations up to date"
|
||||
break
|
||||
fi
|
||||
printf '%s\n' "$output" >&2
|
||||
|
||||
if ! printf '%s' "$output" | grep -q 'P1001'; then
|
||||
log "migrate deploy FAILED — refusing to start the API." >&2
|
||||
if printf '%s' "$output" | grep -q 'P3005'; then
|
||||
log "P3005: the database has tables but no migration history. This is a" >&2
|
||||
log "database that predates Prisma migrations. Baseline it ONCE with:" >&2
|
||||
log " npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$attempt" -ge "$max" ]; then
|
||||
log "database unreachable after ${max} attempts — giving up." >&2
|
||||
exit 1
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep "$delay"
|
||||
done
|
||||
|
||||
exec "$@"
|
||||
@@ -113,5 +113,21 @@ ENV APP_VERSION=$APP_VERSION \
|
||||
GIT_SHA=$GIT_SHA \
|
||||
BUILD_DATE=$BUILD_DATE
|
||||
|
||||
# Pending migrations are applied at container start, before Nest listens —
|
||||
# see the header of the script for why this is done here as well as in the
|
||||
# deploy workflow. Asserted at BUILD time so a missing prisma CLI breaks the
|
||||
# image build rather than a production boot: the runtime layer copies
|
||||
# /repo/node_modules wholesale, and which of these two paths carries the bin
|
||||
# is an implementation detail of pnpm's hoisted linker.
|
||||
COPY docker/api-entrypoint.sh /usr/local/bin/api-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/api-entrypoint.sh
|
||||
RUN for c in /repo/node_modules/.bin/prisma \
|
||||
/repo/packages/database/node_modules/.bin/prisma; do \
|
||||
if [ -x "$c" ]; then echo "prisma CLI found at $c"; exit 0; fi; \
|
||||
done; \
|
||||
echo "FATAL: prisma CLI is not in the runtime layer; api-entrypoint.sh cannot migrate" >&2; \
|
||||
exit 1
|
||||
|
||||
EXPOSE 3001
|
||||
ENTRYPOINT ["/usr/local/bin/api-entrypoint.sh"]
|
||||
CMD ["node", "apps/api/dist/main.js"]
|
||||
|
||||
+61
-14
@@ -12,8 +12,8 @@ carries the reasoning. Close an item *there* as well as here, or the two drift.
|
||||
**Verified against dev at compile time** (re-run before trusting the numbers):
|
||||
|
||||
```
|
||||
policy_types: AUTO, LICENCIAS, MULT
|
||||
policies NULL policyTypeId: 5
|
||||
policy_types: AUTO, LICENCIAS, MULT (+ M_EMPR after 20260815160000)
|
||||
policies NULL policyTypeId: 5 (0 after 20260815160000)
|
||||
policies pending liquidación: 226
|
||||
customers: 1536
|
||||
last tag: v1.0.6 (2026-08-02 02:06 UTC) — 14 commits, 5 migrations behind HEAD
|
||||
@@ -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 |
|
||||
@@ -98,16 +98,31 @@ in the same phone call — (55) 5480-4000.
|
||||
|
||||
## 2. Live data defects — open, and confirmed open today
|
||||
|
||||
### 2.1 `policy_types` is missing `INCENDIO` and `M_EMPR`, and 5 policies are orphaned
|
||||
### 2.1 ~~`policy_types` missing rows + 5 orphaned policies~~ — FIXED 2026-08-15
|
||||
|
||||
`policyTypeId` is `String?` with a plain relation, so Prisma's default is
|
||||
`SetNull`. The spec's recommended `onDelete: Restrict` was **never applied**.
|
||||
Five `m_empr` policies lost their ramo; four of them are pending liquidación
|
||||
and are invisible to every ramo-filtered query — including the pending report
|
||||
§2 is supposed to produce.
|
||||
`SetNull`, and `removePolicyType()` had no in-use guard — deleting a lookup row
|
||||
returned 200 and silently blanked the ramo on every policy using it. That is
|
||||
what happened to `M_EMPR` and its 5 `m_empr` policies.
|
||||
|
||||
Fix alongside the liquidación work (3.1), since it distorts that feature's own
|
||||
report. Source: INSURANCE "Two defects found while verifying this spec".
|
||||
Closed by `20260815160000_policy_type_repair` plus the guard in
|
||||
`policies.service.ts`:
|
||||
|
||||
- `M_EMPR` restored and the 5 policies re-pointed at it, scoped to
|
||||
`policyTypeId IS NULL AND legacySourceTable = 'm_empr'` so it cannot claim a
|
||||
policy blanked for some other reason. Idempotent; verified against dev inside
|
||||
a rolled-back transaction.
|
||||
- **`INCENDIO` deliberately not recreated.** The legacy `INCENDIO` table has
|
||||
1 row and it never loaded, so the type has zero policies — restoring it would
|
||||
only add a dead option to the type picker.
|
||||
- Deleting an in-use policy type, carrier or adjuster now **refuses** with the
|
||||
name and the count. `claims.adjusterId` had the identical `SET NULL` trap and
|
||||
is guarded too. `onDelete: Restrict` at the schema level was not applied —
|
||||
the application guard gives a Spanish message the operator can act on, where
|
||||
a raw FK error would not.
|
||||
- The duplicate `ANA` carrier row (1 policy) was merged into `ANA SEGUROS`
|
||||
(738), since OCR now assigns the carrier automatically and two rows would
|
||||
keep splitting the book.
|
||||
|
||||
### 2.2 ≤41 MULT second settlements were dropped in migration
|
||||
|
||||
@@ -165,12 +180,17 @@ Each of these is a known, deliberate stopping point rather than a bug.
|
||||
- No `SKIPPED_NO_EMAIL` worklist (see 1.9).
|
||||
|
||||
**Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md)
|
||||
- **GMX only.** The dispatcher is a `[provider, pattern]` table plus a parser
|
||||
map, so a second carrier is one function and two entries — but no other
|
||||
layout has been seen, and guessing produces a parser nobody can verify.
|
||||
- **GMX and A.N.A. only.** The dispatcher is a `[provider, pattern]` table plus
|
||||
a parser map, so a third carrier is one function and two entries — but no
|
||||
other layout has been seen, and guessing produces a parser nobody can verify.
|
||||
- **The `recibo` PDF is unread.** The GMX certificate carries no premium at
|
||||
all; reading the separate receipt and pairing it to its certificate is what
|
||||
would let `postPremium` stop being a manual tick.
|
||||
would let `postPremium` stop being a manual tick. A.N.A. prints its premium
|
||||
on the face, so this is a GMX-only gap.
|
||||
- **No `insuranceProviderId` beyond the two OCR carriers.** Confirm resolves
|
||||
the parser's provider to an `insurance_providers` row by name, so GMX and
|
||||
A.N.A. land correctly; a policy typed in by hand still gets whatever the
|
||||
operator picks.
|
||||
- **No versioning.** A re-issued policy arrives as a new certificate with the
|
||||
same number and confirm updates the existing row. Nothing records that this
|
||||
is the 2027 issue of that policy.
|
||||
@@ -184,6 +204,33 @@ 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 would destroy every natively allocated id — now guarded.**
|
||||
`transform_customers.py:246` truncates `customers` and `customer_legacy_refs`
|
||||
(and the other transforms truncate everything they own), then rebuild from
|
||||
Access alone. `migration/native_guard.py` runs before any of it and refuses
|
||||
when the target holds rows Access has never seen; `run_all.py --force-full`,
|
||||
or the checkbox in the REIMPORT confirm, overrides and deletes them. **`--sync`
|
||||
remains the correct path for any database with native rows** — the guard stops
|
||||
the loss, it does not make full mode preserve anything.
|
||||
- **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
|
||||
|
||||
@@ -56,9 +56,11 @@ workflow's last step fails if the API does not report the tag you dispatched.
|
||||
`pre-migrate-<tag>-<timestamp>.sql.gz`, which is exactly what the
|
||||
**Operaciones** admin screen lists and can restore. A dump taken on the CI
|
||||
runner would be unreachable by the only restore path the platform has.
|
||||
3. **`prisma migrate deploy`** — as a workflow *step*, never the container
|
||||
`CMD`. If it were the CMD, N replicas would race each other applying the
|
||||
same migration.
|
||||
3. **`prisma migrate deploy`** — as a workflow *step*, so the schema moves
|
||||
while the OLD code is still serving, which is the order expand/contract is
|
||||
designed around. **The api container repeats this at start** (below); the
|
||||
command is idempotent, so on the normal path the container's run is a
|
||||
no-op.
|
||||
4. **app** — the new api + web images.
|
||||
5. **Verify** — `GET /version` on the running API must report the dispatched
|
||||
tag.
|
||||
@@ -141,6 +143,59 @@ Commit the generated `migrations/<timestamp>_add_foo/` directory. `db push` is
|
||||
now a local-scratch tool only — using it against a database with history
|
||||
desynchronises it from `_prisma_migrations`.
|
||||
|
||||
If you hand-write a migration instead of generating one, check it against what
|
||||
Prisma would have produced before committing — a hand-written file that drifts
|
||||
from `schema.prisma` fails on the *next* deploy, not this one:
|
||||
|
||||
```bash
|
||||
prisma migrate diff \
|
||||
--from-schema-datamodel <schema.prisma at the previous commit> \
|
||||
--to-schema-datamodel packages/database/prisma/schema.prisma --script
|
||||
```
|
||||
|
||||
## Migrations also run at container start
|
||||
|
||||
`docker/api-entrypoint.sh` is the api image's `ENTRYPOINT`. It runs
|
||||
`prisma migrate deploy` and only then `exec`s the API. **If the migration
|
||||
fails the container exits non-zero and the API never listens.**
|
||||
|
||||
That is the point. Serving against a schema that does not match the code is
|
||||
worse than being down, because the failures are partial and silent — a write
|
||||
to a column that does not exist yet breaks one feature while the rest of the
|
||||
app looks healthy.
|
||||
|
||||
This does not replace the workflow step, which still runs first and against
|
||||
the old code. It covers what that step cannot:
|
||||
|
||||
- **`skip_migrate: true`.** Previously that left the schema behind with no
|
||||
further safety net, and the mismatch surfaced later as a runtime error. Now
|
||||
it just moves the migration into the container, so it is a safe choice when
|
||||
the runner cannot reach MySQL.
|
||||
- **Restarts that never touch the workflow** — `restart: unless-stopped`
|
||||
bringing the stack back after a host reboot, or a stack re-applied by hand
|
||||
in Portainer.
|
||||
|
||||
Behaviour worth knowing:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `RUN_MIGRATIONS=false` | Skip and start anyway. Plumbed through both app stack files. For when the schema is being moved by hand. |
|
||||
| `DATABASE_URL` unset | Refuses to start (it would have failed at Nest boot anyway, but this says why). |
|
||||
| Cannot reach the database (**P1001**) | Retries, default 20 × 3s. Covers a cold `db` container and galactus's MagicDNS lookup right after a reboot. `MIGRATE_MAX_ATTEMPTS` / `MIGRATE_RETRY_SECONDS` tune it. |
|
||||
| Any other failure | Exits at once. Retrying a broken migration only delays the same error; **P3005** additionally prints the `migrate resolve --applied 0000_init` hint. |
|
||||
|
||||
**On replicas.** Both stacks are `replicas: 1` and must stay that way for an
|
||||
unrelated reason (the servicios email sweep has no DB lock — see the caveats
|
||||
below). If that ever changes, concurrent `migrate deploy` runs are safe on
|
||||
their own: Prisma takes a database advisory lock, so the others block and then
|
||||
find nothing pending. They would each pay the wait at startup, not corrupt
|
||||
anything.
|
||||
|
||||
The prisma CLI has to be present in the runtime layer for any of this. The
|
||||
image copies `/repo/node_modules` wholesale so it already is, and the
|
||||
Dockerfile **asserts it at build time** — a missing CLI breaks the image
|
||||
build rather than a production boot.
|
||||
|
||||
## galactus vs cubex
|
||||
|
||||
`galactus` is standalone Docker (Portainer endpoint **3**), `cubex` is a 3-node
|
||||
@@ -319,9 +374,10 @@ backup does them (see `deploy/scripts/pre-migrate-backup.mjs`):
|
||||
Portainer serves a self-signed certificate. It is scoped to that one step,
|
||||
which talks to nothing but Portainer. Replacing the certificate and dropping
|
||||
the flag is the real fix.
|
||||
- The runner lives on cubex and must reach the target host's Portainer (9443)
|
||||
**and** MySQL (3306). If it cannot reach 3306, run the migration by hand from
|
||||
a host that can and dispatch with `skip_migrate: true`.
|
||||
- The runner lives on cubex and must reach the target host's Portainer (9443).
|
||||
It should also reach MySQL (3306) for the migrate step, but that is no longer
|
||||
load-bearing — dispatch with `skip_migrate: true` and the api container
|
||||
applies the migrations itself at start.
|
||||
- `bootstrap: true` lets the pre-migrate backup be skipped when no API container
|
||||
exists yet. Use it for a first-ever deploy only — it is the one switch that
|
||||
lets a migration run with no restore point.
|
||||
|
||||
+264
-18
@@ -34,7 +34,7 @@ was **reused, not copied**.
|
||||
|---|---|
|
||||
| API module | `apps/api/src/policy-ocr/` (service, controller, DTOs, matcher, parser) |
|
||||
| Shared OCR seam | `apps/api/src/ocr/ocr.module.ts` |
|
||||
| Tables | `policy_ocr_batches`, `policy_ocr_documents` (`20260801000000_policy_ocr_intake`) |
|
||||
| Tables | `policy_ocr_batches`, `policy_ocr_documents` (`20260801000000_policy_ocr_intake`, extended by `20260815120000_policy_ocr_ana` and `20260815160000_policy_type_repair`) |
|
||||
| Web | `components/PolicyCaptura.tsx` (tab shell), `PolicyOcrIntake.tsx` (upload), `PolicyOcrReview.tsx` (review queue) |
|
||||
| Abilities | `policy:ingest`, `policy:ocr-review` — both **STAFF** |
|
||||
|
||||
@@ -84,8 +84,9 @@ feature's core assumption.
|
||||
Utility statements arrive **bundled, one customer per page** — so there, one
|
||||
page is one document and the parser runs per page. A policy PDF is the
|
||||
opposite: the GMX certificate is a 2-page document where page 1 carries the
|
||||
contract header and page 2 carries the per-coverage table, and **both pages
|
||||
describe the same policy**. So the pipeline concatenates every page's text
|
||||
contract header and page 2 carries the per-coverage table (and the PVL
|
||||
especificación runs to ten), and **every page describes the same policy**. So
|
||||
the pipeline concatenates every page's text
|
||||
(`\n\n` between pages, which also keeps `ocrRawText` readable for debugging)
|
||||
and runs the parser and the matcher exactly **once per file**.
|
||||
|
||||
@@ -133,6 +134,154 @@ customers do occur (one group policy bound by two related parties), and
|
||||
picking arbitrarily would silently book the wrong coverage against the wrong
|
||||
person.
|
||||
|
||||
## GMX ships two unrelated documents for the same policy
|
||||
|
||||
The office downloads both from the same portal, and either can land in a
|
||||
batch. They share only the brand and the policy number, so `parseGmx` is a
|
||||
two-line dispatcher over two real parsers — both returning `provider: "GMX"`,
|
||||
because the matcher keys on the policy number alone and must not care which
|
||||
artifact was uploaded.
|
||||
|
||||
| | **Caratula** (`…_Traduccion.pdf`) | **Especificación** (`…-CondicionesParticulares.pdf`) |
|
||||
|---|---|---|
|
||||
| Language | English (free translation) | Spanish |
|
||||
| Shape | boxed header table + 4-column coverage table | 10 pages of prose, no tables at all |
|
||||
| Header fields | Policy / Insured / Broker / Term / From / To / Currency | insured name, risk location, property description |
|
||||
| Dates, broker, currency field | yes | **none printed** |
|
||||
| Coverages | one row per risk | section heading + `Límite Máximo de Responsabilidad:` |
|
||||
| Parser | `parseGmxCaratula` | `parseGmxEspecificacion` |
|
||||
|
||||
Selected by `isEspecificacion` on the PVL page header
|
||||
(`ESPECIFICACIÓN QUE SE ADHIERE`, `PVL Hogar`, `Nombre del asegurado`).
|
||||
|
||||
Three things about the especificación are worth knowing before touching it:
|
||||
|
||||
- **The policy number's group widths differ between the two.** The caratula
|
||||
reads `007-037-07005947-0000-02` and the especificación
|
||||
`07-037-07006957-00000-01` — 2 digits in the first group, 5 in the fourth.
|
||||
The original parser pinned the widths, so it read one family and returned
|
||||
null on the other. `POLICY_NUMBER_SHAPE` now matches the shape, and since
|
||||
the especificación prints the number on all ten page headers, the ten
|
||||
readings cross-check each other (disagreement is noted, not resolved — the
|
||||
same rule the zona federal parser applies to its clave).
|
||||
- **Coverages are found by anchoring on the limit label and walking backwards
|
||||
for the heading.** There is no row shape to match. A heading is a short line
|
||||
*preceded by a blank line* — that last condition is the whole trick, since
|
||||
length alone cannot tell a heading from the wrapped tail of the paragraph
|
||||
above it (`efectuados.`, `Y CADA PÉRDIDA.`), and without it coverages get
|
||||
named after the last word of the preceding prose.
|
||||
- **Vigencia, agente and prima are absent by design**, not unread. The parser
|
||||
says so in a note, so a reviewer seeing three empty fields does not read it
|
||||
as a broken parse.
|
||||
|
||||
**These three are keyed in by hand** — confirmed 2026-08-14 with Luz, who
|
||||
handles GMX policies at the office. The review screen already has editable
|
||||
inputs for all three, and `postPremium` enables off the *typed* premium, so
|
||||
a hand-entered prima posts to the ledger exactly like a parsed one. No code
|
||||
change was needed to support this; it is a process decision, recorded here
|
||||
because the parser's own note now instructs the reviewer accordingly.
|
||||
|
||||
> **A blank vigencia is silently permanent.** `Policy.policyTo` is nullable
|
||||
> and the renewals window query filters `policyTo: { gte, lte }`
|
||||
> (`renewals.service.ts`), so a policy confirmed without one **never matches
|
||||
> and never gets a renewal notice** — no error, no warning, and nothing
|
||||
> later notices. This is why the parser's note names the consequence instead
|
||||
> of just listing the missing fields.
|
||||
|
||||
An excluded catastrophic risk is recorded as excluded **in the risk label**
|
||||
(`Terremoto o erupción volcánica — Sección Edificio: EXCLUIDO`) with a null
|
||||
amount, never as `0`: a coverage insured for zero and an excluded coverage are
|
||||
the same number and very different facts, and `ParsedCoverage` has no field
|
||||
for the distinction.
|
||||
|
||||
## A.N.A. ships two unrelated faces too
|
||||
|
||||
`A.N.A. Compañía de Seguros` is the Rosarito office's tourist auto book. Same
|
||||
split as GMX, different reason: GMX ships two *documents about one policy*,
|
||||
A.N.A. ships two *products*.
|
||||
|
||||
| | **AUTOMOBILE** (`SPECIAL POLICY FOR TOURISTS`) | **DRIVER´S POLICY** (the office says *licencia*) |
|
||||
|---|---|---|
|
||||
| Insures | a specific car | up to five named drivers, whatever they drive |
|
||||
| Vehicle table | `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` | **none** |
|
||||
| Insured | one `INSURED` cell | numbered `POLICY HOLDER` list |
|
||||
| Value columns | one (`LIMIT OF LIABILITY`) | two (`SUM INSURED`, `PREMIUM`) |
|
||||
| Sections | 9, numbered | 6, unnumbered, **in a different order** |
|
||||
| Parser | `parseAnaAutomobile` | `parseAnaDriverPolicy` |
|
||||
|
||||
Selected by `isAnaDriverPolicy` on the title band.
|
||||
|
||||
The **four automobile products** the office sells — amplia and responsabilidad
|
||||
civil, each annual or by-the-day — are the **same layout with different
|
||||
numbers**. "Amplia" prints a vehicle value and `COVERED` on sections 1–2;
|
||||
"resp. civil" prints `0.00` and `EXCLUDED`. That is data, not a layout, so
|
||||
there is one parser rather than four.
|
||||
|
||||
Things worth knowing before touching the ANA parsers:
|
||||
|
||||
- **These are born-digital portal PDFs**, so `pdftotext -layout` returns exact
|
||||
glyphs and exact columns. The driver's policy parser uses that: `SUM
|
||||
INSURED` and `PREMIUM` print the same shape (`100,000.00 usd.` /
|
||||
`18.70 usd.`) with no per-row label, so **horizontal position is the only
|
||||
thing that separates them**. The split is computed from the header's own
|
||||
column offsets rather than hardcoded, because they shift between products.
|
||||
If a scan ever arrives without column fidelity, every amount is reported as
|
||||
a sum insured and the reviewer is told the split failed.
|
||||
- **The money row is read positionally, not by finding six amounts.** An
|
||||
unused `DISCOUNT` prints as a bare `-`, so an "amounts in order" reading
|
||||
shifts every value one column left on a discounted policy. The parser
|
||||
requires exactly six whitespace-separated cells or reports the row unread.
|
||||
- **Each PDF prints its face two or three times** (ORIGINAL, AGENT COPY, then
|
||||
a summary receipt and three travel ID cards), and the pipeline concatenates
|
||||
every page before parsing. The coverage walk is bounded to the first copy
|
||||
and the driver list to the first `POLICY HOLDER` block. Unbounded, the
|
||||
licencia returns the same person three times — which reads as a
|
||||
three-driver policy, not as a bug, so nothing downstream would catch it.
|
||||
- **Two five-digit numbers sit in the header band** and only one is the agent
|
||||
clave: the other is the agent's own postal code
|
||||
(`ROSARITO, BAJA CALIFORNIA 22710`). Likewise the agent's street address
|
||||
reads `BENITO JUAREZ 25 No.50 INT 38`, three lines above the `No.` cell that
|
||||
holds the policy number — hence the two-space floor after `No.`.
|
||||
- **Sections 6–8 print a PREMIUM where the others print a limit.** $40 is what
|
||||
legal aid *cost*, not a $40 liability limit, so it lands on
|
||||
`ParsedCoverage.premium` (a field GMX never fills) and gets its own column
|
||||
on the review screen. Adding the two together would be meaningless.
|
||||
- **`coveragePeriodDays` matters here and nowhere else.** A.N.A. sells 3- and
|
||||
4-day policies. `Policy.coveragePeriodDays` defaults to 365, so a weekend
|
||||
policy left at the default sits in the renewals window a year out. The term
|
||||
is derived from the two dates and cross-checked against the printed `DAYS`
|
||||
cell; a disagreement is noted rather than resolved.
|
||||
|
||||
Exclusions follow the GMX rule — recorded in the risk label
|
||||
(`MATERIAL DAMAGE — VEHICLE: EXCLUDED`) with a null amount, never as `0`. It
|
||||
matters more here: a responsabilidad-civil policy prints `0.00` for material
|
||||
damage, so the two are visually identical on the page.
|
||||
|
||||
### Vehicles and drivers
|
||||
|
||||
A.N.A. is the first provider whose face carries either, so confirm now writes
|
||||
`Vehicle` and `InsuredDriver` rows alongside the `Policy`
|
||||
(`applyVehiclesAndDrivers`). The parsed values are stored on the document as
|
||||
`extractedVehiclesJson` / `extractedDriversJson` and shown read-only in the
|
||||
review queue, so a misread VIN is catchable before it is applied.
|
||||
|
||||
Both inserts skip a row that already exists on the policy, matched on the
|
||||
identifier the document prints — VIN then plate for a vehicle (A.N.A.'s
|
||||
TRAILER and TOWING slots have no VIN), licence number then name for a driver.
|
||||
The case that forces this is confirming a **renewal** onto an existing policy:
|
||||
a blind insert leaves the customer with the same VIN listed twice and no way
|
||||
to tell which row the renewal belongs to.
|
||||
|
||||
Nothing is ever updated or deleted there. A vehicle whose plate changed lands
|
||||
as a second row for a human to reconcile — the safe half of the mistake, since
|
||||
an overwrite would destroy the only record of what was insured last term.
|
||||
|
||||
The vehicle table is parsed **by token role, not by column offset**, because
|
||||
`BODY` is the cell that wraps: `PACIFICA` is one token and `GENESIS SEDAN` is
|
||||
two, so a fixed token count reads the VIN out of the wrong slot on the second.
|
||||
The 17-character VIN is the anchor and `BODY` is whatever sits between the
|
||||
make and it.
|
||||
|
||||
## What the parser reads, and the field it cannot
|
||||
|
||||
`ParsedPolicy` fields are all nullable on purpose: each carrier prints a
|
||||
@@ -145,6 +294,12 @@ insured, broker (→ `Policy.agentName`), legal address, ZIP, `policyFrom` /
|
||||
per-coverage table (risk, insured amount, deductible, loss participation)
|
||||
preserved verbatim.
|
||||
|
||||
Read from an A.N.A. face: all of the above except additional insured and
|
||||
broker parens, **plus** the premium (A.N.A. prints it — see below), the policy
|
||||
fee, the total, the term in days, the vehicle table, and the named drivers
|
||||
with their US licence numbers. The tax and the agent clave have no column in
|
||||
the schema and ride in the notes.
|
||||
|
||||
> **The GMX certificate carries no premium.** Not "sometimes missing" — the
|
||||
> document does not have the figure. It lives on GMX's **separate `recibo`
|
||||
> PDF**. The parser leaves `netPremium` / `policyFee` / `brokerFee` / `total`
|
||||
@@ -157,20 +312,71 @@ This is also why confirm never overwrites an existing `Policy.netPremium`
|
||||
with null: the certificate not carrying a premium is not evidence that the
|
||||
premium is gone.
|
||||
|
||||
A.N.A.'s faces do print one — the `DISCOUNT / PREMIUM / POLICY FEE / TAX /
|
||||
LOCAL TAX / TOTAL` row is on the same page — so an ANA document reaches the
|
||||
review queue with `netPremium` populated and `postPremium` already ticked.
|
||||
|
||||
Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
|
||||
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
|
||||
and free text, and normalising them would lose the distinction.
|
||||
|
||||
## Policy type and carrier
|
||||
|
||||
Confirm sets `Policy.policyTypeId` and `Policy.insuranceProviderId` from what
|
||||
the parser read.
|
||||
|
||||
| document | `policyTypeName` |
|
||||
|---|---|
|
||||
| ANA `AUTOMOBILE` | `AUTO` |
|
||||
| ANA `DRIVER´S POLICY` | `LICENCIAS` |
|
||||
| GMX caratula **and** especificación | `MULT` |
|
||||
|
||||
The parser emits a **name**, never an id — it is a pure function over text and
|
||||
must not reach for the database, so `resolveLookups()` in the service turns the
|
||||
name into a foreign key. A renamed lookup row is then a data change rather than
|
||||
a parser change.
|
||||
|
||||
**Resolve, never create.** A missing `policy_types` row means a human deleted
|
||||
it, and silently recreating it would undo that with no record. The field stays
|
||||
null and the reviewer adds the row through the lookups screen. An explicit
|
||||
`policyTypeId` / `insuranceProviderId` on the confirm payload always wins.
|
||||
|
||||
Two judgement calls worth recording:
|
||||
|
||||
- **GMX is `MULT`, not `INCENDIO`.** The caratula's own header reads "Multiple
|
||||
Policy / Home" and the especificación is "PVL Hogar" — one product, two
|
||||
artifacts. `MULT` is the live row carrying 769 of them; `INCENDIO` is
|
||||
fire-only and no policy in the book has ever used it.
|
||||
- **The parser's provider code is not the carrier's row name.** The office's
|
||||
book is filed under `ANA SEGUROS`, so `PROVIDER_ROW_NAME` maps `ANA` onto it.
|
||||
A bare `ANA` row with 1 policy also existed and is merged away by
|
||||
`20260815160000_policy_type_repair`.
|
||||
|
||||
> **Deleting a lookup row used to be silent data loss.** `policies.policyTypeId`,
|
||||
> `policies.insuranceProviderId` and `claims.adjusterId` are all
|
||||
> `ON DELETE SET NULL`, and the lookups screen deleted unconditionally — so the
|
||||
> delete returned 200 and blanked the field on every row that used it. That is
|
||||
> how `M_EMPR` vanished and left 5 policies with no ramo, found months later by
|
||||
> querying. All three deletes now refuse while the row is in use, naming it and
|
||||
> the count. See `assertLookupUnused` and BACKLOG §2.1.
|
||||
|
||||
## Confirm: what actually gets written
|
||||
|
||||
Per confirmed document, in order:
|
||||
|
||||
1. **The `Policy` row** — updated if a policy was matched, created under the
|
||||
picked customer if not. Only non-null `extracted*` fields are written; null
|
||||
never overwrites existing data.
|
||||
2. **A `PolicyDocument`** — the source PDF is streamed into the policy's
|
||||
storage namespace and attached, so the paperwork stays with the policy.
|
||||
3. **Optionally a `Transaction`** — `INSURANCE` domain, negative amount
|
||||
never overwrites existing data. `policyTypeId` and `insuranceProviderId` are
|
||||
resolved first (above) and left untouched when unresolvable, so an existing
|
||||
policy never loses a type or carrier it already had.
|
||||
2. **`Vehicle` and `InsuredDriver` rows** — for the providers whose face
|
||||
carries them (A.N.A.; never GMX Hogar), skipping any that already exist on
|
||||
the policy. See *Vehicles and drivers* above.
|
||||
3. **A `PolicyDocument`** — the source PDF is streamed into the policy's
|
||||
storage namespace and attached, so the paperwork stays with the policy. Its
|
||||
`documentType` is named after whichever parser claimed the page
|
||||
(`ANA_POLICY`, `GMX_POLICY`).
|
||||
4. **Optionally a `Transaction`** — `INSURANCE` domain, negative amount
|
||||
(a charge), `captureSource: "OCR"`, `captureRef` = the document id.
|
||||
|
||||
The ledger write is **opt-in twice over**: staff must tick `postPremium`
|
||||
@@ -210,20 +416,60 @@ feature is disabled.
|
||||
|
||||
## Tests
|
||||
|
||||
`apps/api/src/policy-ocr/parsers/policy-parser.spec.ts` — 8 cases, all
|
||||
against verbatim text extracted from one real document,
|
||||
`HC_Folio_000767_Traduccion.pdf`: provider detection from the wordmark and
|
||||
from the footer URL, the header fields, every coverage row off the second
|
||||
page, the deductible/loss-participation strings, the missing-premium note,
|
||||
the broker line with the agent-number parens absent, and a page with no GMX
|
||||
signal at all (which must yield no provider rather than a bad guess).
|
||||
`apps/api/src/policy-ocr/parsers/policy-parser.spec.ts` — 58 cases against
|
||||
verbatim text extracted from five real documents, indentation and blank lines
|
||||
included (the column positions are what the parser reads, so a cleaned-up
|
||||
fixture would test nothing — and on A.N.A.'s driver's policy the offsets are
|
||||
literally the only thing separating two columns).
|
||||
|
||||
From `HC_Folio_000767_Traduccion.pdf` (caratula): provider detection from the
|
||||
wordmark and from the footer URL, the header fields, every coverage row off
|
||||
the second page, the deductible/loss-participation strings, the
|
||||
missing-premium note, the broker line with the agent-number parens absent,
|
||||
and a page with no GMX signal at all (which must yield no provider rather
|
||||
than a bad guess).
|
||||
|
||||
From `007_LGS-HGMX_07006957_01_0-CondicionesParticulares.pdf`
|
||||
(especificación): the differently-grouped policy number, the risk location
|
||||
read across its wrapped line, the empty `Asegurado Adicional` cell that must
|
||||
not capture the next line, the absent-by-design fields, currency taken from
|
||||
the USD limits rather than the M.N. sublimits in the body prose, a limit
|
||||
split under `Edificio` / `Contenidos` sub-labels, a limit printed on the
|
||||
label's own line, a deductible stated as a sentence *above* its limit, a
|
||||
sublimit block whose amount sits after both a blank line and a page break,
|
||||
the excluded earthquake coverage, and the hydrometeorological deductible and
|
||||
coinsurance pulled from their own per-zone block.
|
||||
|
||||
Plus four cases in `apps/api/src/policies/lookup-delete-guard.spec.ts` pinning
|
||||
the refusal that stops a lookup delete from silently blanking the rows that use
|
||||
it, and four in the parser suite on the policy-type NAME each document yields.
|
||||
|
||||
From the three A.N.A. PDFs: brand detection (and that GMX's layout rules
|
||||
cannot claim an ANA page), the header band, DD MM YYYY read out of three
|
||||
separate column cells, the six money cells with `DISCOUNT` printed as a bare
|
||||
`-`, the vehicle row with a one-word and a two-word `BODY` cell, the empty
|
||||
TRAILER/TOWING slots, the agent street number and postal code that must *not*
|
||||
be read as the policy number and clave, the declared values labelled by item
|
||||
slot, the `$500.00` inside the deductible sentence that is not a sum insured,
|
||||
the per-person/per-accident split in both of its printed forms, an add-on's
|
||||
figure recorded as a premium, section 9's parenthesised limit, the by-the-day
|
||||
term, the excluded sections, the column-position split on the driver's policy,
|
||||
its different section order, and — for both faces — that a doubled or tripled
|
||||
input yields one set of coverages and one driver rather than one per copy.
|
||||
|
||||
Four of the GMX cases are regression tests for ways the parser can silently attach
|
||||
the *wrong* value rather than none — a neighbouring coverage's prose read as
|
||||
a deductible, the page-level `DEDUCIBLES:` paragraph read as one, a coverage
|
||||
named after a wrapped prose tail, and one section's per-zone deductible
|
||||
adopted by the coverage above it. Each was a real defect caught by running
|
||||
the parser against the full ten-page document.
|
||||
|
||||
## Not built
|
||||
|
||||
- **Only GMX.** The dispatcher (`detectPolicyProvider`) is a table of
|
||||
`[provider, pattern]` pairs plus a `parsers` map, so adding ANA or Qualitas
|
||||
is a parser function and two entries — but no other carrier's layout has
|
||||
been seen yet, and guessing at one produces a parser nobody can verify.
|
||||
- **GMX and A.N.A. only.** The dispatcher (`detectPolicyProvider`) is a table
|
||||
of `[provider, pattern]` pairs plus a `parsers` map, so adding Qualitas is a
|
||||
parser function and two entries — but no other carrier's layout has been
|
||||
seen yet, and guessing at one produces a parser nobody can verify.
|
||||
- **The `recibo` PDF.** Reading the premium off GMX's separate receipt
|
||||
document, and pairing it to the certificate it belongs to, is the obvious
|
||||
next piece. It is what would let `postPremium` stop being a manual tick.
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Refuse a full re-import that would delete platform-native data.
|
||||
|
||||
A full `run_all.py` pass truncates and rebuilds every table it owns from the
|
||||
Access extract:
|
||||
|
||||
transform_customers.py customers, customer_legacy_refs
|
||||
transform_properties.py properties, property_services, service_documents,
|
||||
trust_accounts
|
||||
transform_policies.py policies + installments, vehicles, drivers,
|
||||
beneficiaries, claims, adjusters, policy_types,
|
||||
insurance_providers
|
||||
transform_transactions.py transactions, type_transactions, exchange_rates
|
||||
transform_bank.py bank tables
|
||||
blob_extract.py service_documents, policy_documents
|
||||
|
||||
That was harmless while the platform was a read-only mirror of Access: every
|
||||
row came from the extract, so wiping and rebuilding lost nothing. It stopped
|
||||
being harmless when the platform started minting rows Access has never heard
|
||||
of — portal NUMids from the allocator (apps/api/src/customers/numid.service.ts),
|
||||
customers created in the staff UI, OCR-captured policies, app-booked ledger
|
||||
rows, uploaded documents. None of those come back.
|
||||
|
||||
`--sync` already avoids all of it: it upserts legacy rows against the existing
|
||||
refs and leaves everything else alone. So this guard does not try to teach the
|
||||
full path to preserve anything — it stops the full path when there is something
|
||||
to preserve, and points at the additive one.
|
||||
|
||||
python native_guard.py --env prod # report only, exit 3 if blocking
|
||||
python run_all.py --env prod --stage # runs this first, refuses on 3
|
||||
python run_all.py --env prod --force-full # ignore the guard (deletes them)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from dbenv import connect
|
||||
|
||||
STG = Path(__file__).parent / "output"
|
||||
|
||||
# Exit code the orchestrator looks for. Distinct from 1 so a connection failure
|
||||
# or a bad query is not silently read as "native rows found".
|
||||
BLOCKED = 3
|
||||
|
||||
# transform_customers.py mints these when a legacy row carries no id of its own.
|
||||
# They are regenerated by every full pass, so they are legacy-owned, not native.
|
||||
SYNTHETIC_REF_PREFIXES = ("rownum_", "insrow_")
|
||||
|
||||
# App-uploaded documents are stored as `<prefix>/<parent>/<uuid>.<ext>`, while
|
||||
# blob_extract writes `<prefix>/<parent>/<stagedtable>_<row>_<col>.<ext>`.
|
||||
# service_documents carries no provenance column, so the key shape is the only
|
||||
# signal available — approximate, and reported as such. Matched in MySQL rather
|
||||
# than in Python so the whole scan stays one round trip per table.
|
||||
UUID_KEY_SQL = "/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\."
|
||||
|
||||
|
||||
def staged_legacy_ids() -> dict[str, set[str]] | None:
|
||||
"""The ids Access will re-create, per source system.
|
||||
|
||||
None when the staged Parquet is absent — which is not the same as "no legacy
|
||||
ids". Returning an empty set there would mark all 1,171 refs as native and
|
||||
block every run; returning None lets the caller say "cannot verify" instead.
|
||||
"""
|
||||
sources = {"utilities": "stg_utilities", "insurance": "stg_seguros"}
|
||||
out: dict[str, set[str]] = {}
|
||||
for system, folder in sources.items():
|
||||
path = STG / folder / "datgral.parquet"
|
||||
if not path.exists():
|
||||
return None
|
||||
df = pd.read_parquet(path, columns=["num_id"])
|
||||
ids = set()
|
||||
for v in df["num_id"].astype("string"):
|
||||
if v is None or pd.isna(v):
|
||||
continue
|
||||
v = str(v).strip()
|
||||
if v.endswith(".0"): # some numeric ids serialize as "521.0"
|
||||
v = v[:-2]
|
||||
if v and v != "0":
|
||||
ids.add(v)
|
||||
out[system] = ids
|
||||
return out
|
||||
|
||||
|
||||
def native_refs(cur, staged: dict[str, set[str]] | None) -> tuple[int, list[str]]:
|
||||
"""Legacy refs with no counterpart in the Access extract.
|
||||
|
||||
This is the check that catches an allocated portal NUMid: the customer holds
|
||||
a perfectly ordinary-looking (utilities, DATGRAL, '1172') ref, so "customer
|
||||
has no refs" does not see it. Only comparing against staging does.
|
||||
"""
|
||||
cur.execute(
|
||||
"SELECT sourceSystem, legacyId FROM customer_legacy_refs ORDER BY sourceSystem, legacyId"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
if staged is None:
|
||||
return 0, []
|
||||
|
||||
found = []
|
||||
for system, legacy_id in rows:
|
||||
if legacy_id.startswith(SYNTHETIC_REF_PREFIXES):
|
||||
continue
|
||||
known = staged.get(system)
|
||||
# An unknown source system has no extract to compare against, so it
|
||||
# cannot be re-created either — treat it as native rather than ignoring.
|
||||
if known is None or legacy_id not in known:
|
||||
found.append(f"{system}/{legacy_id}")
|
||||
return len(found), found
|
||||
|
||||
|
||||
def scan(conn) -> tuple[list[tuple[str, int, str]], bool]:
|
||||
"""(label, count, detail) per source of native rows, plus whether staging
|
||||
was available to verify the refs."""
|
||||
cur = conn.cursor()
|
||||
staged = staged_legacy_ids()
|
||||
findings: list[tuple[str, int, str]] = []
|
||||
|
||||
ref_count, ref_examples = native_refs(cur, staged)
|
||||
if ref_count:
|
||||
shown = ", ".join(ref_examples[:8])
|
||||
more = f" (+{ref_count - 8} more)" if ref_count > 8 else ""
|
||||
findings.append(("customer_legacy_refs", ref_count, f"{shown}{more}"))
|
||||
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM customers c"
|
||||
" WHERE NOT EXISTS (SELECT 1 FROM customer_legacy_refs r WHERE r.customerId = c.id)"
|
||||
)
|
||||
n = cur.fetchone()[0]
|
||||
if n:
|
||||
findings.append(("customers", n, "created in the staff UI, no legacy ref"))
|
||||
|
||||
# Every transform writes legacyId on what it loads, so a NULL is the app's.
|
||||
for table, detail in (
|
||||
("transactions", "booked in the app (captura, OCR, manual)"),
|
||||
("policies", "created in the app or captured by policy OCR"),
|
||||
("properties", "created in the app"),
|
||||
("vehicles", "created in the app"),
|
||||
("bank_transactions", "booked in the chequera"),
|
||||
):
|
||||
cur.execute(f"SELECT COUNT(*) FROM {table} WHERE legacyId IS NULL")
|
||||
n = cur.fetchone()[0]
|
||||
if n:
|
||||
findings.append((table, n, detail))
|
||||
|
||||
# blob_extract always writes originalColumn; the app never does.
|
||||
cur.execute("SELECT COUNT(*) FROM policy_documents WHERE originalColumn IS NULL")
|
||||
n = cur.fetchone()[0]
|
||||
if n:
|
||||
findings.append(("policy_documents", n, "uploaded in the app"))
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM service_documents WHERE storageKey REGEXP %s",
|
||||
(UUID_KEY_SQL,))
|
||||
n = cur.fetchone()[0]
|
||||
if n:
|
||||
findings.append(("service_documents", n, "uploaded in the app (key shape, approximate)"))
|
||||
|
||||
return findings, staged is not None
|
||||
|
||||
|
||||
def report(findings, staged_ok: bool, env: str) -> int:
|
||||
print(f"=== Verificación de datos nativos (env={env}) ===", flush=True)
|
||||
|
||||
if not staged_ok:
|
||||
print(
|
||||
" ! No hay Parquet en migration/output, así que no se pueden verificar\n"
|
||||
" los refs contra el extracto de Access. Ejecute con --stage.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not findings:
|
||||
print(" Sin filas nativas. Una reimportación completa no destruye nada.", flush=True)
|
||||
return 0 if staged_ok else BLOCKED
|
||||
|
||||
total = sum(n for _, n, _ in findings)
|
||||
print(f" {total} filas existen SÓLO en la plataforma y se perderían:", flush=True)
|
||||
for label, n, detail in findings:
|
||||
print(f" {n:>7} {label:<22} {detail}", flush=True)
|
||||
print(
|
||||
"\n Una reimportación completa vacía estas tablas y las reconstruye desde\n"
|
||||
" Access, que no conoce ninguna de estas filas.\n"
|
||||
"\n Use la sincronización aditiva (run_all.py --sync), que respeta lo\n"
|
||||
" capturado en la plataforma. Para reimportar de todos modos y BORRARLAS,\n"
|
||||
" ejecute run_all.py --force-full.",
|
||||
flush=True,
|
||||
)
|
||||
return BLOCKED
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
ap.add_argument("--env", default="dev")
|
||||
args = ap.parse_args()
|
||||
|
||||
conn = connect(args.env)
|
||||
try:
|
||||
findings, staged_ok = scan(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
raise SystemExit(report(findings, staged_ok, args.env))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,6 +15,12 @@ Then:
|
||||
./.venv/bin/python run_all.py --env dev # data only (staging already present)
|
||||
./.venv/bin/python run_all.py --env prod --stage # re-extract from Access first, then load
|
||||
|
||||
A full pass truncates and rebuilds every table it owns from the Access extract,
|
||||
so anything the platform minted itself — allocated portal NUMids, customers
|
||||
created in the staff UI, OCR-captured policies, app-booked ledger rows, uploaded
|
||||
documents — is destroyed. native_guard.py runs first and refuses when the target
|
||||
database holds any of it; --force-full overrides and deletes them.
|
||||
|
||||
--sync swaps the truncate+rebuild steps for the additive upsert ones. It reads
|
||||
the same staged Parquet, so it needs --stage too unless a previous run left
|
||||
migration/output populated on this machine — which is never true in a
|
||||
@@ -83,6 +89,33 @@ SYNC_STEPS = [
|
||||
]
|
||||
|
||||
|
||||
# native_guard.py exits with this when the target database holds rows that only
|
||||
# exist in the platform. Kept in step with the constant there.
|
||||
GUARD_BLOCKED = 3
|
||||
|
||||
|
||||
def guard(env: str, force: bool) -> None:
|
||||
"""Stop a full pass that would delete platform-native rows.
|
||||
|
||||
Only the full path needs this: --sync upserts legacy rows against the
|
||||
existing refs and leaves everything else alone, so it cannot lose anything.
|
||||
Run after staging, because the guard verifies legacy refs against the staged
|
||||
Parquet and cannot tell an allocated NUMid from an Access one without it.
|
||||
"""
|
||||
cmd = [PY, str(HERE / "native_guard.py"), "--env", env]
|
||||
print("+ " + " ".join(cmd), flush=True)
|
||||
r = subprocess.run(cmd)
|
||||
if r.returncode == GUARD_BLOCKED and not force:
|
||||
sys.exit(r.returncode)
|
||||
if r.returncode == GUARD_BLOCKED and force:
|
||||
print(
|
||||
"\n! --force-full: continuando y BORRANDO las filas nativas listadas.\n",
|
||||
flush=True,
|
||||
)
|
||||
elif r.returncode:
|
||||
sys.exit(r.returncode)
|
||||
|
||||
|
||||
def run(cmd: list[str], step: int | None = None, total: int | None = None) -> None:
|
||||
# The "[paso i/N] name" marker is a contract with the Operaciones screen,
|
||||
# which parses the last one to show progress. Emitting it here rather than
|
||||
@@ -103,6 +136,8 @@ def main() -> None:
|
||||
help="re-run the raw staging load first (needs the Access files + mdbtools)")
|
||||
ap.add_argument("--sync", action="store_true",
|
||||
help="upsert legacy rows and archive removed legacy rows; preserve manual rows")
|
||||
ap.add_argument("--force-full", action="store_true",
|
||||
help="run the full truncate+rebuild even when it deletes platform-native rows")
|
||||
args = ap.parse_args()
|
||||
|
||||
steps = SYNC_STEPS if args.sync else STEPS
|
||||
@@ -116,6 +151,11 @@ def main() -> None:
|
||||
run([PY, str(HERE / "load_staging.py"), "--output-dir", str(HERE / "output")],
|
||||
step=1, total=total)
|
||||
|
||||
# Deliberately not counted as a step: it is a precondition, it takes a
|
||||
# second, and the Operaciones progress bar parses those numbers.
|
||||
if not args.sync:
|
||||
guard(args.env, args.force_full)
|
||||
|
||||
for i, step in enumerate(steps, start=1 + offset):
|
||||
cmd = [PY, str(HERE / step), "--env", args.env]
|
||||
if args.sync:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jorgecuadros-platform",
|
||||
"version": "1.0.15",
|
||||
"version": "1.0.19",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.15",
|
||||
"version": "1.0.19",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"types": "generated/client/index.d.ts",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- ANA Seguros policy OCR: the vehicle/driver tables and the printed term in
|
||||
-- days that ANA's tourist book carries and GMX's property book does not.
|
||||
ALTER TABLE `policy_ocr_documents`
|
||||
ADD COLUMN `extractedCoveragePeriodDays` INTEGER NULL,
|
||||
ADD COLUMN `extractedVehiclesJson` JSON NULL,
|
||||
ADD COLUMN `extractedDriversJson` JSON NULL;
|
||||
|
||||
-- The parser note trail outgrew VARCHAR(191): a nine-section ANA policy runs
|
||||
-- past it routinely, and the notes that got cut were the tail ones — the
|
||||
-- "could not read X" warnings the reviewer most needs.
|
||||
ALTER TABLE `policy_ocr_documents`
|
||||
MODIFY COLUMN `matchNote` TEXT NULL;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- The policy type the OCR parser read the product as, resolved to a
|
||||
-- `policy_types` row at confirm time.
|
||||
ALTER TABLE `policy_ocr_documents`
|
||||
ADD COLUMN `extractedPolicyTypeName` VARCHAR(191) NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Repair: M_EMPR was deleted from the lookups screen and took its policies'
|
||||
-- type with it.
|
||||
--
|
||||
-- `policies.policyTypeId` is ON DELETE SET NULL, and removePolicyType() had no
|
||||
-- in-use guard, so deleting the row silently blanked the field on every policy
|
||||
-- referencing it — 5 of them, all from the legacy `m_empr` table. The guard
|
||||
-- against a repeat ships in the same change as this migration. What follows
|
||||
-- repairs what already happened.
|
||||
--
|
||||
-- Idempotent on purpose: `policy_types.name` is UNIQUE so the INSERT IGNORE is
|
||||
-- a no-op once the row exists, and the UPDATE is scoped to rows that are still
|
||||
-- null AND came from that one legacy table, so it can never claim a policy
|
||||
-- whose type was blanked for some other reason.
|
||||
INSERT IGNORE INTO `policy_types` (`id`, `name`) VALUES (UUID(), 'M_EMPR');
|
||||
|
||||
UPDATE `policies` p
|
||||
JOIN `policy_types` pt ON pt.`name` = 'M_EMPR'
|
||||
SET p.`policyTypeId` = pt.`id`
|
||||
WHERE p.`policyTypeId` IS NULL
|
||||
AND p.`legacySourceTable` = 'm_empr';
|
||||
|
||||
-- INCENDIO is deliberately NOT recreated. It is the other row the migration
|
||||
-- would have produced, but no policy in the book has ever carried it, so
|
||||
-- adding it back would only put a dead option in the type picker.
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Merge the duplicate ANA carrier.
|
||||
--
|
||||
-- `insurance_providers` holds both "ANA" (1 policy) and "ANA SEGUROS" (738).
|
||||
-- They are one carrier, and OCR is about to start assigning it automatically —
|
||||
-- picking either row while both exist would keep splitting the book.
|
||||
--
|
||||
-- "ANA SEGUROS" is the survivor because it is where the 738 already are.
|
||||
--
|
||||
-- Written as joins rather than subqueries so that BOTH statements are no-ops
|
||||
-- when either row is absent (a fresh database, or one where this was already
|
||||
-- tidied by hand). A subquery form would resolve to NULL and blank the
|
||||
-- carrier off every ANA policy.
|
||||
UPDATE `policies` p
|
||||
JOIN `insurance_providers` dup ON dup.`id` = p.`insuranceProviderId` AND dup.`name` = 'ANA'
|
||||
JOIN `insurance_providers` keep ON keep.`name` = 'ANA SEGUROS'
|
||||
SET p.`insuranceProviderId` = keep.`id`;
|
||||
|
||||
DELETE dup FROM `insurance_providers` dup
|
||||
JOIN `insurance_providers` keep ON keep.`name` = 'ANA SEGUROS'
|
||||
WHERE dup.`name` = 'ANA'
|
||||
-- Belt and braces: never drop a row that still has policies hanging off
|
||||
-- it, whatever the UPDATE above did or did not manage to move.
|
||||
AND NOT EXISTS (SELECT 1 FROM `policies` p WHERE p.`insuranceProviderId` = dup.`id`);
|
||||
@@ -379,8 +379,11 @@ model PolicyDocument {
|
||||
/// source PDF and optionally writes a premium Transaction.
|
||||
model PolicyOcrBatch {
|
||||
id String @id @default(uuid())
|
||||
/// Which insurance provider portal the batch came from. "GMX" today;
|
||||
/// future providers (AXA, GNP, …) extend the parser, not this table.
|
||||
/// Which insurance provider portal the batch came from — "GMX", "ANA", or
|
||||
/// "GMX + ANA" when one upload mixed them. Set by the pipeline from what
|
||||
/// the parsers actually claimed, not asked of the uploader, so it can
|
||||
/// never contradict the documents. Future providers extend the parser,
|
||||
/// not this table.
|
||||
provider String @default("GMX")
|
||||
status PolicyOcrBatchStatus @default(UPLOADED)
|
||||
uploadedById String
|
||||
@@ -442,10 +445,27 @@ model PolicyOcrDocument {
|
||||
extractedBrokerFee Decimal? @db.Decimal(12, 2)
|
||||
extractedTotal Decimal? @db.Decimal(12, 2)
|
||||
/// Per-coverage rows from the GMX "Material damages" / "Additional risk"
|
||||
/// tables — preserved verbatim so a missing premium receipt still leaves
|
||||
/// the coverages auditable.
|
||||
/// tables and ANA's numbered risk sections — preserved verbatim so a
|
||||
/// missing premium receipt still leaves the coverages auditable.
|
||||
extractedCoveragesJson Json?
|
||||
extractedPremiumPayment String?
|
||||
/// Printed term length. ANA sells 3- and 4-day tourist policies, so
|
||||
/// leaving `Policy.coveragePeriodDays` at its 365 default would overstate
|
||||
/// a weekend policy by a year.
|
||||
extractedCoveragePeriodDays Int?
|
||||
/// `ParsedVehicle[]` off ANA's ITEM/YEAR/MAKE/BODY/SERIAL/PLATES table.
|
||||
/// Written to `Vehicle` rows on confirm; kept here so the review screen
|
||||
/// shows what was read before anything is applied.
|
||||
extractedVehiclesJson Json?
|
||||
/// `ParsedDriver[]` — the insured on ANA's automobile face, the numbered
|
||||
/// POLICY HOLDER list on its driver's policy. Written to `InsuredDriver`
|
||||
/// rows on confirm.
|
||||
extractedDriversJson Json?
|
||||
/// The `PolicyType.name` the parser read the product as ("AUTO",
|
||||
/// "LICENCIAS", "MULT"). A NAME, not an id — the parser never touches the
|
||||
/// database, so confirm resolves it against `policy_types` and leaves
|
||||
/// `Policy.policyTypeId` null if there is no such row.
|
||||
extractedPolicyTypeName String?
|
||||
|
||||
// Match by `Policy.policyNumber` → existing Policy / Customer.
|
||||
matchedPolicyId String?
|
||||
@@ -456,7 +476,11 @@ model PolicyOcrDocument {
|
||||
/// normal; >1 means the policy number is shared across customers and a
|
||||
/// human must pick.
|
||||
matchCandidates Json?
|
||||
matchNote String?
|
||||
/// Text, not VARCHAR(191): this carries the parser's whole note trail, and
|
||||
/// a multi-section ANA policy runs past 191 characters routinely. Silently
|
||||
/// truncating it drops the tail notes, which are the ones that say what
|
||||
/// could NOT be read.
|
||||
matchNote String? @db.Text
|
||||
|
||||
reviewedById String?
|
||||
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* NUMid recycle audit.
|
||||
*
|
||||
* Prints which portal NUMids (customer_legacy_refs, utilities/DATGRAL) are dead
|
||||
* enough to hand to a new customer, and which are merely quiet. Read-only: it
|
||||
* writes nothing and reassigns nothing.
|
||||
*
|
||||
* node scripts/numid-audit.mjs # summary + both candidate tiers
|
||||
* node scripts/numid-audit.mjs --csv # full per-NUMid table on stdout
|
||||
* node scripts/numid-audit.mjs --numid 501
|
||||
*
|
||||
* Needs DATABASE_URL. Run it against PROD before acting on anything: the tiers
|
||||
* describe whatever database it is pointed at, and a stale copy will happily
|
||||
* report a NUMid as empty that prod has been billing all year.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// Relative, not "@jorgecuadros/database": the workspace link is not always
|
||||
// present at the repo root, and this script has to run from a bare checkout and
|
||||
// from inside the API container alike.
|
||||
import pkg from "../packages/database/generated/client/index.js";
|
||||
|
||||
const { PrismaClient } = pkg;
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Anything here means the id carries history a new owner would inherit.
|
||||
* Counted, not sampled: a single row in any of them disqualifies.
|
||||
*/
|
||||
const HISTORY_COLUMNS = [
|
||||
"veh",
|
||||
"trust",
|
||||
"stmt",
|
||||
"ocr",
|
||||
"enl",
|
||||
"elog",
|
||||
"ash",
|
||||
"nopago",
|
||||
];
|
||||
|
||||
const n = (v) => (v == null ? 0 : Number(v));
|
||||
const hasHistory = (r) => HISTORY_COLUMNS.some((c) => n(r[c]) > 0);
|
||||
const zeroBalance = (r) => n(r.balMxn) === 0 && n(r.balUsd) === 0;
|
||||
|
||||
/**
|
||||
* EMPTY — the id was created and never used. Safe for an allocator to take
|
||||
* without a human looking, subject to the legacy check below.
|
||||
*
|
||||
* "Never transacted" means zero movements once the synthetic opening-balance row
|
||||
* is removed; that row exists for all 1,171 NUMids and is not evidence of use.
|
||||
* Services are checked as anySvc rather than activeSvc, because a deactivated
|
||||
* water account still says a person once lived behind this id.
|
||||
*/
|
||||
const isEmpty = (r) =>
|
||||
n(r.realTx) === 0 &&
|
||||
zeroBalance(r) &&
|
||||
n(r.anySvc) === 0 &&
|
||||
n(r.anyPol) === 0 &&
|
||||
n(r.insRef) === 0 &&
|
||||
n(r.hasEmail) === 0 &&
|
||||
!hasHistory(r);
|
||||
|
||||
/**
|
||||
* DORMANT — used once, quiet for years, owes nothing. NOT auto-allocatable:
|
||||
* a returning snowbird is indistinguishable from an abandoned account here.
|
||||
*/
|
||||
const isDormant = (r) =>
|
||||
!isEmpty(r) &&
|
||||
n(r.realTx36m) === 0 &&
|
||||
zeroBalance(r) &&
|
||||
n(r.activeSvc) === 0 &&
|
||||
n(r.activePol) === 0 &&
|
||||
!hasHistory(r);
|
||||
|
||||
function line(r) {
|
||||
return (
|
||||
` ${String(r.numid).padStart(5)} ${(r.name || "(sin nombre)").slice(0, 30).padEnd(30)}` +
|
||||
` last=${(r.lastRealTx ? new Date(r.lastRealTx).toISOString().slice(0, 10) : "never").padStart(10)}` +
|
||||
` tx=${String(n(r.realTx)).padStart(3)}` +
|
||||
` bal=${n(r.balMxn).toFixed(2).padStart(10)}` +
|
||||
` svc=${n(r.anySvc)}` +
|
||||
` pol=${n(r.anyPol)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const sql = readFileSync(join(HERE, "numid-audit.sql"), "utf8");
|
||||
const rows = await prisma.$queryRawUnsafe(sql);
|
||||
|
||||
const one = args.indexOf("--numid");
|
||||
if (one !== -1) {
|
||||
const want = Number(args[one + 1]);
|
||||
const r = rows.find((x) => Number(x.numid) === want);
|
||||
if (!r) {
|
||||
console.log(`NUMid ${want} is not in the utilities/DATGRAL pool.`);
|
||||
return;
|
||||
}
|
||||
console.log(JSON.stringify(r, (_k, v) => (typeof v === "bigint" ? Number(v) : v), 2));
|
||||
console.log(
|
||||
`\nverdict: ${isEmpty(r) ? "EMPTY" : isDormant(r) ? "DORMANT" : "IN USE"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.includes("--csv")) {
|
||||
const cols = Object.keys(rows[0]);
|
||||
console.log(cols.join(","));
|
||||
for (const r of rows) {
|
||||
console.log(cols.map((c) => JSON.stringify(r[c] ?? "")).join(","));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const empty = rows.filter(isEmpty);
|
||||
const dormant = rows.filter(isDormant);
|
||||
const max = rows.reduce((m, r) => Math.max(m, Number(r.numid)), 0);
|
||||
|
||||
console.log(`pool: ${rows.length} NUMids, max ${max}`);
|
||||
console.log(` EMPTY (never used, auto-allocatable): ${empty.length}`);
|
||||
console.log(` DORMANT (quiet, needs a human): ${dormant.length}`);
|
||||
console.log(` IN USE: ${rows.length - empty.length - dormant.length}`);
|
||||
|
||||
console.log("\nEMPTY");
|
||||
empty.forEach((r) => console.log(line(r)));
|
||||
console.log("\nDORMANT");
|
||||
dormant.forEach((r) => console.log(line(r)));
|
||||
|
||||
console.log(
|
||||
"\nNOTE: this audit sees the platform only. Every NUMid here also exists in\n" +
|
||||
"Access, and freakma republishes DATGRAL in full on each export, so an id\n" +
|
||||
"reassigned here comes back under its old owner unless it is removed at the\n" +
|
||||
"source or the NUMid is routed to the platform. Confirm against prod\n" +
|
||||
"datosfreak before reassigning.",
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
-- One row per portal NUMid, with every signal that says whether the id is in use.
|
||||
-- Consumed by scripts/numid-audit.mjs, which applies the tier rules.
|
||||
--
|
||||
-- POOL. customer_legacy_refs where sourceSystem='utilities' AND sourceTable='DATGRAL'.
|
||||
-- That pair IS the portal "Security Number" the login screen asks for.
|
||||
-- insurance/DATGRAL is a DIFFERENT id space running to 4000 and sharing the same
|
||||
-- sourceTable name; drawing from it would hand out an id the portal cannot resolve.
|
||||
--
|
||||
-- WHY THE OBVIOUS RULES FIND NOTHING.
|
||||
-- "every owned row count is zero" -> 0 of 1,171. Migration gave every NUMid
|
||||
-- at least one property and one transaction.
|
||||
-- "no transaction in the last N years" -> 0 of 1,171. Every customer carries a
|
||||
-- synthetic Jan-1 opening-balance row, so
|
||||
-- everyone looks active in the current year.
|
||||
-- The opening-balance row has to be subtracted before any of this means anything,
|
||||
-- which is what `bf` below does and why `real_tx` exists.
|
||||
--
|
||||
-- BALANCE-FORWARD DETECTION IS TWO-SHAPED ON PURPOSE.
|
||||
-- transform_transactions.py:120 mints a transaction type literally named
|
||||
-- 'BALANCE FORWARD'. Databases loaded before that change carry the same rows with
|
||||
-- typeId NULL, dated Jan 1, legacySourceTable='datos2' -- 1,170 of them, exactly one
|
||||
-- per customer. Matching the type name alone floors nothing on such a database, and
|
||||
-- every balance below silently becomes a raw lifetime sum: the same double-count that
|
||||
-- read the whole book as +20.6M MXN in credit before d173c9e. Match both shapes.
|
||||
--
|
||||
-- Balances otherwise follow BillingService exactly -- voided out, outstanding out,
|
||||
-- superseded rows out (BALANCE_FLOOR_JOIN / NOT_SUPERSEDED, billing.service.ts:179-210).
|
||||
|
||||
WITH bf AS (
|
||||
SELECT t.id, t.customerId, t.transactionDate
|
||||
FROM transactions t
|
||||
LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||
WHERE t.voidedAt IS NULL
|
||||
AND (
|
||||
tt.nameEn = 'BALANCE FORWARD'
|
||||
OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1
|
||||
AND t.legacySourceTable = 'datos2')
|
||||
)
|
||||
),
|
||||
bfloor AS (
|
||||
SELECT customerId, MAX(transactionDate) AS floorDate FROM bf GROUP BY customerId
|
||||
),
|
||||
real_tx AS (
|
||||
SELECT t.* FROM transactions t
|
||||
WHERE t.voidedAt IS NULL AND t.id NOT IN (SELECT id FROM bf)
|
||||
),
|
||||
pool AS (
|
||||
SELECT CAST(r.legacyId AS UNSIGNED) AS numid,
|
||||
c.id AS cid,
|
||||
REPLACE(REPLACE(COALESCE(c.name,''),'\n',' '),'\t',' ') AS name,
|
||||
IF(c.archivedAt IS NULL,0,1) AS archived,
|
||||
IF(c.email IS NULL OR c.email='',0,1) AS hasEmail
|
||||
FROM customer_legacy_refs r
|
||||
JOIN customers c ON c.id = r.customerId
|
||||
WHERE r.sourceSystem='utilities' AND r.sourceTable='DATGRAL'
|
||||
)
|
||||
SELECT
|
||||
p.numid, p.cid AS customerUuid, p.name, p.archived, p.hasEmail,
|
||||
-- EXISTS, not a join: 16 customers hold more than one insurance ref (several
|
||||
-- insurance rows folded into one customer), and joining them fans this result
|
||||
-- out past one row per NUMid — 1,188 rows for a 1,171-id pool.
|
||||
EXISTS(SELECT 1 FROM customer_legacy_refs i
|
||||
WHERE i.customerId=p.cid AND i.sourceSystem='insurance') AS insRef,
|
||||
|
||||
COALESCE((SELECT ROUND(SUM(t.amount),2) FROM transactions t
|
||||
LEFT JOIN bfloor f ON f.customerId=t.customerId
|
||||
WHERE t.customerId=p.cid AND t.voidedAt IS NULL AND t.outstanding=0
|
||||
AND t.currency='MXN'
|
||||
AND (f.floorDate IS NULL OR t.transactionDate>=f.floorDate)),0) AS balMxn,
|
||||
COALESCE((SELECT ROUND(SUM(t.amount),2) FROM transactions t
|
||||
LEFT JOIN bfloor f ON f.customerId=t.customerId
|
||||
WHERE t.customerId=p.cid AND t.voidedAt IS NULL AND t.outstanding=0
|
||||
AND t.currency='USD'
|
||||
AND (f.floorDate IS NULL OR t.transactionDate>=f.floorDate)),0) AS balUsd,
|
||||
|
||||
(SELECT COUNT(*) FROM transactions t
|
||||
WHERE t.customerId=p.cid AND t.voidedAt IS NULL AND t.outstanding=1) AS nopago,
|
||||
(SELECT COUNT(*) FROM real_tx t WHERE t.customerId=p.cid) AS realTx,
|
||||
(SELECT COUNT(*) FROM real_tx t WHERE t.customerId=p.cid
|
||||
AND t.transactionDate >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)) AS realTx12m,
|
||||
(SELECT COUNT(*) FROM real_tx t WHERE t.customerId=p.cid
|
||||
AND t.transactionDate >= DATE_SUB(CURDATE(), INTERVAL 36 MONTH)) AS realTx36m,
|
||||
(SELECT DATE(MAX(t.transactionDate)) FROM real_tx t WHERE t.customerId=p.cid) AS lastRealTx,
|
||||
|
||||
(SELECT COUNT(*) FROM properties pr WHERE pr.customerId=p.cid AND pr.archivedAt IS NULL) AS props,
|
||||
-- services are counted BOTH ways: an inactive service is still a record of the id
|
||||
-- having been used, so the auto tier requires zero of any kind.
|
||||
(SELECT COUNT(*) FROM property_services ps JOIN properties pr ON pr.id=ps.propertyId
|
||||
WHERE pr.customerId=p.cid AND pr.archivedAt IS NULL) AS anySvc,
|
||||
(SELECT COUNT(*) FROM property_services ps JOIN properties pr ON pr.id=ps.propertyId
|
||||
WHERE pr.customerId=p.cid AND pr.archivedAt IS NULL AND ps.active=1) AS activeSvc,
|
||||
(SELECT COUNT(*) FROM policies po WHERE po.customerId=p.cid AND po.archivedAt IS NULL
|
||||
AND (po.policyTo IS NULL OR po.policyTo >= CURDATE())) AS activePol,
|
||||
(SELECT COUNT(*) FROM policies po WHERE po.customerId=p.cid AND po.archivedAt IS NULL) AS anyPol,
|
||||
(SELECT COUNT(*) FROM vehicles v WHERE v.customerId=p.cid) AS veh,
|
||||
(SELECT COUNT(*) FROM trust_accounts ta JOIN properties pr ON pr.id=ta.propertyId
|
||||
WHERE pr.customerId=p.cid) AS trust,
|
||||
(SELECT COUNT(*) FROM statement_documents s WHERE s.matchedCustomerId=p.cid) AS stmt,
|
||||
(SELECT COUNT(*) FROM policy_ocr_documents o WHERE o.matchedCustomerId=p.cid) AS ocr,
|
||||
(SELECT COUNT(*) FROM email_notification_log e WHERE e.customerId=p.cid) AS enl,
|
||||
(SELECT COUNT(*) FROM email_log e WHERE e.customerId=p.cid) AS elog,
|
||||
(SELECT COUNT(*) FROM account_status_history a WHERE a.customerId=p.cid) AS ash
|
||||
FROM pool p
|
||||
ORDER BY p.numid;
|
||||
Reference in New Issue
Block a user