rmancinasandClaude Opus 5 d645ba51d3
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m39s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m21s
feat(policy-ocr): read A.N.A. Seguros' two policy faces
A.N.A. is the Rosarito office's tourist auto book and the second carrier
the policy OCR pipeline reads. It ships two unrelated faces, and the split
is different from GMX's: GMX ships two documents about one policy, A.N.A.
ships two products.

  AUTOMOBILE (SPECIAL POLICY FOR TOURISTS)  insures a car; vehicle table,
                                            9 numbered sections, one
                                            LIMIT OF LIABILITY column
  DRIVER'S POLICY (the office: "licencia")  insures up to 5 named drivers;
                                            no vehicle at all, 6 unnumbered
                                            sections in a different order,
                                            SUM INSURED + PREMIUM columns

The four automobile products the office sells (amplia / responsabilidad
civil, annual / by-the-day) are the same layout with different numbers, so
they get one parser rather than four.

These are born-digital portal PDFs, so pdftotext -layout returns exact
columns and the driver's-policy parser uses that: its two value columns
print the same shape (100,000.00 usd. / 18.70 usd.) with no per-row label,
so horizontal position is the only thing separating them. The split comes
from the header's own offsets, not a constant, because they shift between
products; when it can't be read every amount is reported as a sum insured
and the reviewer is told, rather than half the premiums being filed as
coverage limits.

Three things the layout will punish a naive read for:

- Each PDF prints its face two or three times (ORIGINAL, AGENT COPY, then
  a receipt and three travel 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
  rather than as a bug.
- The money row is read positionally off its header. An unused DISCOUNT
  prints as a bare "-", so "find the six amounts" shifts every value one
  column left on a discounted policy.
- Two five-digit numbers sit in the header band and only one is the agent
  clave; the agent's street address is "BENITO JUAREZ 25 No.50 INT 38",
  three lines above the No. cell holding the policy number.

Sections 6-8 print a PREMIUM where the others print a limit, so
ParsedCoverage gains an optional `premium` (GMX never fills it) and the
review table a column: $40 is what legal aid cost, not a $40 liability
limit. Exclusions follow the GMX rule and go in the risk label with a null
amount -- which matters more here, since a responsabilidad-civil policy
prints 0.00 for material damage and the two are identical on the page.

Also in this change:

- coveragePeriodDays is parsed and written. 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. Derived from
  the dates, cross-checked against the printed DAYS cell, disagreement
  noted not resolved.
- Vehicles and named drivers are parsed, shown read-only in review, and
  written as Vehicle / InsuredDriver rows on confirm, skipping any already
  on the policy (VIN then plate; licence then name). The case that forces
  the skip is confirming a renewal onto an existing policy. Nothing is ever
  updated or deleted -- a changed plate lands as a second row for a human.
- Batch.provider is set from what the parsers actually claimed instead of
  being hardcoded "GMX", so a mixed upload is labelled as mixed and the
  header can never contradict its own documents. PolicyDocument.documentType
  follows the same rule (was hardcoded GMX_POLICY).
- matchNote becomes TEXT. It was VARCHAR(191) and the note trail was sliced
  to 190 chars, which cut the tail notes -- the "could not read X" ones.
- The policy detail page renders an array coveragesJson as a table. Both
  shapes have always been possible there, but the object renderer was the
  only one, so an OCR-confirmed policy showed a row per array index labelled
  "0", "1", "2" with [object Object] as the value. ANA makes that routine.

GMX is untouched behaviourally; its two parsers now spread a shared empty
base instead of listing every null field. 29 new parser cases against
verbatim pdftotext output of three real ANA PDFs, 53 in the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:09:16 -07:00
2026-08-11 13:17:45 -07:00

Jorge Cuadros & Asociados — Platform

Internal platform for a Baja California insurance brokerage and property-services firm: a single expedient joining each client's properties/services, insurance policies, account statement, and the firm's checkbook. It replaces a legacy PHP/Access app (see RESUME.md and PLAN.md for the full history and rebuild rationale, and docs/BACKLOG.md for everything still outstanding).

The UI is Spanish-first; the codebase and this document are in English.


Stack

Layer Tech Port
Web Next.js 14 (App Router, React 18) 3000
API NestJS 10 · Passport local + express-session · Argon2 3001
Database MySQL 8 via Prisma 5 (@jorgecuadros/database workspace pkg) 3306
Migration Python 3 pipeline (legacy Access → staging → transforms)

Monorepo managed with pnpm workspaces. Node >= 20.


Repository layout

apps/
  web/        Next.js frontend  (@jorgecuadros/web)
  api/        NestJS backend    (@jorgecuadros/api)
    scripts/seed-user.mjs   idempotent admin seeder
packages/
  database/   Prisma schema + generated client (@jorgecuadros/database)
    prisma/schema.prisma
migration/    One-off Python ETL from the legacy Access DB (run_all.py)
docker/       Dockerfiles for api + web
docker-compose.yml          mysql + api + web
.env.example                copy to .env

API feature modules: auth, users, customers, policies, properties, billing, bank, reports, notifications, renewals, mail, statements, policy-ocr, ocr, storage, settings, ops.

Web routes: /inicio, /clientes, /polizas (+ /polizas/captura, policy PDF OCR capture), /servicios, /estado-cuenta, /banco (chequera), /recibos (utility statement OCR capture), /notificaciones (mass email + renewal avisos; /renovaciones is an alias onto its Pólizas tab), /reportes, /catalogos, /operaciones (DB ingest/backup, ADMIN), /usuarios, /login.

Two OCR intakes share one OcrProvider seam (src/ocr/, Tesseract today): utility statements → ledger rows (docs/STATEMENT_OCR.md) and carrier policy PDFs → Policy rows (docs/POLICY_OCR.md). Both need tesseract-ocr, tesseract-ocr-data-spa, poppler-utils and object storage; each reports its own availability and disables only itself if either is missing.


Prerequisites

  • Node.js >= 20 and pnpm (npm i -g pnpm)
  • Docker (for MySQL, or bring your own MySQL 8)
  • Python 3 — only if you run the legacy data migration

Run it locally (development)

1. Install

pnpm install

pnpm blocks postinstall build scripts by default; the trusted ones (argon2, prisma, @prisma/client, @prisma/engines, @nestjs/core) are allowlisted in pnpm-workspace.yaml, so the native builds run automatically.

2. Configure environment

cp .env.example .env

Then edit .env. For the Docker MySQL below the defaults already line up; just set a real SESSION_SECRET:

DATABASE_URL=mysql://jorgecuadros:jorgecuadros@localhost:3306/jorgecuadros
SESSION_SECRET=<any long random string>
WEB_ORIGIN=http://localhost:3000
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001

The API loads DATABASE_URL, SESSION_SECRET, WEB_ORIGIN, and optional 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

docker compose up -d mysql

(Or point DATABASE_URL at an existing MySQL 8 instance.)

4. Create the schema + generate the Prisma client

The schema is managed with prisma db push (no migration history committed):

pnpm --filter @jorgecuadros/database exec prisma db push
pnpm --filter @jorgecuadros/database generate

5. Seed a sign-in user

node apps/api/scripts/seed-user.mjs

Idempotent (upsert by email). Defaults — override with SEED_EMAIL, SEED_PASSWORD, SEED_NAME:

  • email: admin@jorgecuadros.local
  • password: ChangeMe!2026
  • role: ADMIN

6. Run the apps (two terminals)

# API  → http://localhost:3001
pnpm --filter @jorgecuadros/api start:dev

# Web  → http://localhost:3000
pnpm --filter @jorgecuadros/web dev

Root shortcuts also exist: pnpm dev:api, pnpm dev:web.

7. Log in

Open http://localhost:3000, sign in with the seeded credentials. Sessions are cookie-based and last 8 hours.


Run it with Docker (full stack)

Builds MySQL + API + web from docker-compose.yml:

export SESSION_SECRET=$(openssl rand -hex 32)
docker compose up --build

Web on http://localhost:3000, API on http://localhost:3001. SESSION_SECRET is required (compose fails without it). After first boot, push the schema and seed a user against the container DB:

docker compose exec api node apps/api/scripts/seed-user.mjs

Common commands

Task Command
Install pnpm install
Dev — API pnpm dev:api
Dev — Web pnpm dev:web
Build all pnpm build
Generate Prisma client pnpm prisma:generate
Push schema (dev) pnpm --filter @jorgecuadros/database exec prisma db push
Prisma Studio pnpm prisma:studio
API tests pnpm --filter @jorgecuadros/api test
Lint (web / api) pnpm --filter @jorgecuadros/web lint · ... /api lint
Seed admin user node apps/api/scripts/seed-user.mjs

Auth & roles

Session auth via Passport local strategy; passwords hashed with Argon2 (no plaintext, unlike the legacy app). Roles gate the UI and API — e.g. managing /catalogos and /usuarios requires the appropriate ability (ADMIN / MANAGER). New users are created by an admin in /usuarios; the first admin comes from the seed script above.


Legacy data migration (optional)

migration/ holds the one-off Python ETL that lifts data out of the old Microsoft Access database into MySQL.

pip install -r migration/requirements.txt
python migration/run_all.py

⚠️ run_all.py truncates and reloads all downstream tables. Never run an individual transform_*.py in isolation — it orphans dependent tables. See migration/RECONCILIATION.md for details.


Scheduled jobs

The API runs two automatic email sweeps. Neither cadence is in the source: both are stored in app_settings and edited at /notificaciones → "Programación de envíos" (ADMIN, setting:manage), taking effect immediately without a restart. Shipped defaults:

Job Default What it does
Pólizas on, 06:00 daily (America/Tijuana) Renewal avisos at 30/15 days before expiry and 7 days after.
Servicios off All four mass-email jobs in order, same as "Ejecutar todos".

A scheduled run never uses the UI's send flags — in particular it ignores debug, so a forgotten test toggle cannot silently stop customer mail. Full detail in docs/MASS_EMAIL_NOTIFICATIONS.md.

Sending needs SES_* in the environment. Without it the API still boots and logs mail to stdout in dev; in production every send fails loudly and is recorded as FAILED rather than quietly going nowhere.


Production notes

  • Use pnpm --filter @jorgecuadros/database exec prisma migrate deploy if/when a committed migration history is adopted; today dev uses db push.
  • Set a strong SESSION_SECRET and a locked-down DATABASE_URL.
  • The API expects WEB_ORIGIN to match the browser origin for session cookies.
  • Documents are stored in MinIO in the deployed environment (see RESUME.md).
S
Description
No description provided
Readme
2.2 MiB
Languages
TypeScript 82.3%
Python 10.1%
CSS 3.7%
JavaScript 3%
Shell 0.5%
Other 0.4%