From b59abda895a822edb4241e5e798649b99187d95f Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Sat, 1 Aug 2026 01:04:09 -0700 Subject: [PATCH] feat(captura): fold recibo OCR into Captura as an automatic mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scanning a stack of bills and keying them in are the same daily job, ending in the same ledger path, so OCR intake becomes a mode of the capture screen instead of a second menu entry: - components/Captura.tsx holds the mode switch; the manual check form moves verbatim to components/ManualCheckCapture.tsx and the OCR intake to components/StatementIntake.tsx. - /estado-cuenta/lote opens on manual, /recibos on automatic — both render Captura, so batch-review links and old bookmarks still land right. - Nav drops "Recibos (OCR)"; "Captura" covers both, with a NavLink.aliases field so /recibos still highlights it. Also fixes the "El almacenamiento de documentos no está configurado" failure staff hit on upload. Uploading with no object storage configured used to succeed, then die on the first put minutes later, leaving a FAILED batch whose only explanation was that string. createBatch now refuses up front, GET /statements/status reports storageAvailable alongside ocrAvailable, and the intake tab explains the situation instead of offering an upload that cannot work. S3_* documented in .env.example (deploy stacks already set it). Co-Authored-By: Claude Opus 5 --- .env.example | 9 + .../src/statements/statements.controller.ts | 11 +- apps/api/src/statements/statements.service.ts | 13 + apps/api/src/storage/storage.service.ts | 10 + apps/web/src/app/estado-cuenta/lote/page.tsx | 552 +----------------- apps/web/src/app/recibos/[id]/page.tsx | 2 +- apps/web/src/app/recibos/page.tsx | 265 +-------- apps/web/src/components/AppShell.tsx | 29 +- apps/web/src/components/Captura.tsx | 90 +++ .../web/src/components/ManualCheckCapture.tsx | 526 +++++++++++++++++ apps/web/src/components/StatementIntake.tsx | 272 +++++++++ apps/web/src/lib/api.ts | 10 +- docs/RECEIPT_CAPTURE_SPEC.md | 16 +- 13 files changed, 981 insertions(+), 824 deletions(-) create mode 100644 apps/web/src/components/Captura.tsx create mode 100644 apps/web/src/components/ManualCheckCapture.tsx create mode 100644 apps/web/src/components/StatementIntake.tsx diff --git a/.env.example b/.env.example index ef96662..efc9fbc 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,15 @@ SESSION_SECRET=change-me-to-a-random-string WEB_ORIGIN=http://localhost:3000 NEXT_PUBLIC_API_ORIGIN=http://localhost:3001 +# Object storage (MinIO / S3) for document blobs and scanned receipt pages. +# Without S3_ENDPOINT + credentials the API still boots, but every document +# upload/download and the whole recibo OCR intake are disabled. Credentials fall +# back to MINIO_ROOT_USER / MINIO_ROOT_PASSWORD when the S3_* pair is unset. +S3_ENDPOINT=http://localhost:9000 +S3_BUCKET=jorgecuadros-documents +S3_ACCESS_KEY= +S3_SECRET_KEY= + # Login the "Operaciones" screen runs mysqldump/mysql as. Optional locally: when # unset it falls back to the DATABASE_URL credentials, which a dev MySQL usually # grants enough for. Required in any deployment, where the application user has diff --git a/apps/api/src/statements/statements.controller.ts b/apps/api/src/statements/statements.controller.ts index 33f66e3..abb0f28 100644 --- a/apps/api/src/statements/statements.controller.ts +++ b/apps/api/src/statements/statements.controller.ts @@ -43,10 +43,17 @@ export class StatementsController { return (req.user as { id: string } | undefined)?.id ?? ""; } - /** Whether this deployment can OCR at all — the UI hides upload without it. */ + /** + * Whether this deployment can ingest scans at all — the UI hides automatic + * capture without it. Both halves are needed: OCR to read the page, object + * storage to keep it. + */ @Get("status") async status() { - return { ocrAvailable: await this.statements.ocrAvailable() }; + return { + ocrAvailable: await this.statements.ocrAvailable(), + storageAvailable: this.statements.storageAvailable(), + }; } @Get("batches") diff --git a/apps/api/src/statements/statements.service.ts b/apps/api/src/statements/statements.service.ts index edee42a..f4497b5 100644 --- a/apps/api/src/statements/statements.service.ts +++ b/apps/api/src/statements/statements.service.ts @@ -53,6 +53,11 @@ export class StatementsService { return this.ocr.available(); } + /** Scans are stored as blobs, so no object storage means no intake. */ + storageAvailable(): boolean { + return this.storage.available; + } + // --- ingest --------------------------------------------------------------- /** @@ -75,6 +80,14 @@ export class StatementsService { "El servidor no tiene OCR instalado; no se pueden procesar recibos.", ); } + // Checked here rather than at the first `put`, which would only surface as + // a FAILED batch minutes later. + if (!this.storage.available) { + throw new BadRequestException( + "El almacenamiento de documentos no está configurado; no se pueden " + + "guardar los recibos escaneados.", + ); + } const batch = await this.prisma.statementBatch.create({ data: { serviceKind, uploadedById, label, fileCount: files.length }, diff --git a/apps/api/src/storage/storage.service.ts b/apps/api/src/storage/storage.service.ts index 6dd102c..aa4e420 100644 --- a/apps/api/src/storage/storage.service.ts +++ b/apps/api/src/storage/storage.service.ts @@ -73,6 +73,16 @@ export class StorageService implements OnModuleInit { } } + /** + * Whether the deployment has object storage at all. Callers use this to + * refuse work up front instead of failing halfway through — a recibo batch + * that dies on its first `put` leaves a FAILED batch and no explanation the + * office can act on. + */ + get available(): boolean { + return this.client !== null; + } + private require(): S3Client { if (!this.client) { throw new ServiceUnavailableException( diff --git a/apps/web/src/app/estado-cuenta/lote/page.tsx b/apps/web/src/app/estado-cuenta/lote/page.tsx index f8d2292..26e1ca2 100644 --- a/apps/web/src/app/estado-cuenta/lote/page.tsx +++ b/apps/web/src/app/estado-cuenta/lote/page.tsx @@ -1,557 +1,11 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; import { AppShell } from "@/components/AppShell"; -import { CustomerPicker } from "@/components/CustomerPicker"; -import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api"; -import { useCan } from "@/lib/abilities"; -import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels"; -import type { - BatchCreateInput, - BillingFacets, - ByCheckResponse, - Currency, - LedgerCurrency, - TransactionDomain, -} from "@/lib/types"; - -/** - * Batch capture by check — the "Editor" screen from the legacy system - * (docs/RECEIPT_CAPTURE_SPEC.md §1.2). - * - * Staff key many customers' receipts against ONE physical check before cutting - * it, then check that the captured total matches the check's amount. That - * reconciliation is the whole point, so the running total is the most prominent - * thing on the page and an optional "importe del cheque" field turns it into a - * live difference. - * - * No batch entity is persisted: `checkNumber` is a plain column, and grouping - * by it answers every by-check question (see the "Reporte por cheque" report). - */ - -const DOMAINS: { key: TransactionDomain; label: string }[] = [ - { key: "UTILITY", label: "Servicios" }, - { key: "INSURANCE", label: "Seguros" }, - { key: "TRUST", label: "Fideicomiso" }, -]; - -interface Line { - /** Local row key — lines have no server identity until the batch posts. */ - key: number; - customerId: string; - customerName: string; - amount: string; - reference: string; - period: string; - outstanding: boolean; -} - -function blankLine(key: number): Line { - return { - key, - customerId: "", - customerName: "", - amount: "", - reference: "", - period: "", - outstanding: false, - }; -} +import { Captura } from "@/components/Captura"; +/** Daily capture, opened on the manual (key-by-hand) mode. */ export default function BatchCapturePage() { return ( - + ); } - -function BatchCapture() { - const canCapture = useCan("ledger:create"); - const [facets, setFacets] = useState(null); - - // Check-level fields — shared by every line. - const [domain, setDomain] = useState("UTILITY"); - const [currency, setCurrency] = useState("MXN"); - const [typeId, setTypeId] = useState(""); - const [checkNumber, setCheckNumber] = useState(""); - const [transactionDate, setTransactionDate] = useState( - new Date().toISOString().slice(0, 10), - ); - /** The physical check's amount, for reconciliation only — never submitted. */ - const [checkAmount, setCheckAmount] = useState(""); - - const [lines, setLines] = useState([blankLine(1), blankLine(2), blankLine(3)]); - const [nextKey, setNextKey] = useState(4); - - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [posted, setPosted] = useState(null); - - useEffect(() => { - getBillingFacets().then(setFacets).catch(() => setFacets(null)); - }, []); - - const filled = lines.filter( - (l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)), - ); - - // Charges are captured as positive numbers and signed on submit, matching - // MovementForm — staff type what's on the bill, not a negative. - const total = useMemo( - () => - filled - .filter((l) => !l.outstanding) - .reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0), - [filled], - ); - const outstandingTotal = useMemo( - () => - filled - .filter((l) => l.outstanding) - .reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0), - [filled], - ); - - const checkAmt = Number(checkAmount); - const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt); - const diff = hasCheckAmt ? checkAmt - total : 0; - const reconciled = hasCheckAmt && Math.abs(diff) < 0.005; - - function update(key: number, patch: Partial) { - setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l))); - } - - function addLine() { - setLines((ls) => [...ls, blankLine(nextKey)]); - setNextKey((k) => k + 1); - } - - function removeLine(key: number) { - setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key))); - } - - async function submit(e: React.FormEvent) { - e.preventDefault(); - if (!checkNumber.trim()) { - setError("Indica el número de cheque."); - return; - } - if (filled.length === 0) { - setError("Captura al menos una línea con cliente y monto."); - return; - } - const dupes = filled - .map((l) => l.customerId) - .filter((id, i, arr) => arr.indexOf(id) !== i); - if (dupes.length) { - const names = filled - .filter((l) => dupes.includes(l.customerId)) - .map((l) => l.customerName); - if ( - !window.confirm( - `Hay más de una línea para el mismo cliente (${[...new Set(names)].join( - ", ", - )}). ¿Continuar?`, - ) - ) - return; - } - - const payload: BatchCreateInput = { - domain, - transactionDate, - checkNumber: checkNumber.trim(), - currency: currency as Currency, - typeId: typeId || undefined, - lines: filled.map((l) => ({ - customerId: l.customerId, - // Every line of a check batch is a charge the office paid out. - amount: -Math.abs(Number(l.amount)), - reference: l.reference.trim() || undefined, - period: l.period.trim() || undefined, - outstanding: l.outstanding || undefined, - })), - }; - - setSaving(true); - setError(null); - try { - await createMovementBatch(payload); - // Re-read through the by-check view so the confirmation shows what's - // actually stored (including anything captured against this check - // earlier), not just what this request sent. - setPosted(await getByCheck(payload.checkNumber)); - } catch (e2) { - setError((e2 as Error)?.message ?? "No se pudo guardar el lote."); - } finally { - setSaving(false); - } - } - - function reset() { - setPosted(null); - setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]); - setNextKey((k) => k + 3); - setCheckNumber(""); - setCheckAmount(""); - } - - if (!canCapture) { - return ( -
- No tienes permiso para capturar movimientos. -
- ); - } - - if (posted) { - return ( - <> -
-
-

Lote capturado

-

- Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "} - {posted.count === 1 ? "movimiento" : "movimientos"} -

-
-
- - - Volver a estado de cuenta - -
-
- -
- {posted.totals.map((t) => ( -
- {t.currency} - - Total del cheque {formatMoney(t.total, t.currency)} - - {formatNumber(t.count)} movimientos -
- ))} - {posted.outstandingCount > 0 && ( -
- - {formatNumber(posted.outstandingCount)} sin fondos (no suman al - total) - -
- )} -
- -
- - - - - - - - - - - - {posted.items.map((i) => ( - - - - - - - - ))} - -
ClienteReferenciaPeriodoEstadoMonto
- - {i.customerName} - - {i.reference || "—"}{i.period || "—"}{i.outstanding ? "Sin fondos" : "Pagado"} - - {formatMoney(i.amount, i.currency)} - -
-
- -

- Para imprimir la conciliación, usa el reporte{" "} - - Reporte por cheque - - . -

- - ); - } - - return ( - <> -
-
-

Captura por cheque

-

- Captura los recibos de varios clientes contra un mismo cheque y - concilia el total antes de guardar. -

-
- - Cancelar - -
- - {error &&
{error}
} - -
-
-

- Datos del cheque -

-
- - - - - - -
-
- -
-
-

- Recibos ({formatNumber(filled.length)}) -

- -
- -
- - - - - - - - - - - - {lines.map((l) => ( - - - - - - - - - ))} - -
Cliente *ReferenciaPeriodo - Monto * - Sin fondos -
- - update(l.key, { customerId: id, customerName: name }) - } - /> - - - update(l.key, { reference: e.target.value }) - } - /> - - update(l.key, { period: e.target.value })} - placeholder="2026-07" - /> - - update(l.key, { amount: e.target.value })} - placeholder="0.00" - /> - - - update(l.key, { outstanding: e.target.checked }) - } - aria-label="Sin fondos" - /> - - -
-
-
- -
-

- Conciliación -

-
-
- {currency} - - Capturado {formatMoney(String(-total), currency)} - - {formatNumber(filled.filter((l) => !l.outstanding).length)} recibos -
- {outstandingTotal > 0 && ( -
- - Sin fondos{" "} - {formatMoney(String(-outstandingTotal), currency)}{" "} - (no suma al cheque) - -
- )} - {hasCheckAmt && ( -
- - {reconciled ? ( - Cuadra con el cheque - ) : ( - <> - Diferencia{" "} - - {formatMoney(String(diff), currency)} - - - )} - -
- )} -
-
- -
- - - Cancelar - -
-
- - ); -} diff --git a/apps/web/src/app/recibos/[id]/page.tsx b/apps/web/src/app/recibos/[id]/page.tsx index cf26b90..d63d497 100644 --- a/apps/web/src/app/recibos/[id]/page.tsx +++ b/apps/web/src/app/recibos/[id]/page.tsx @@ -125,7 +125,7 @@ function BatchReview({ id }: { id: string }) {

- Volver + Volver a captura diff --git a/apps/web/src/app/recibos/page.tsx b/apps/web/src/app/recibos/page.tsx index 0f6b605..6c806fc 100644 --- a/apps/web/src/app/recibos/page.tsx +++ b/apps/web/src/app/recibos/page.tsx @@ -1,270 +1,15 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import Link from "next/link"; import { AppShell } from "@/components/AppShell"; -import { - getStatementStatus, - listStatementBatches, - uploadStatementBatch, -} from "@/lib/api"; -import { useCan } from "@/lib/abilities"; -import { formatDate, SERVICE_KIND_LABELS, serviceKindLabel } from "@/lib/labels"; -import type { ServiceKind, StatementBatch, StatementBatchStatus } from "@/lib/types"; +import { Captura } from "@/components/Captura"; /** - * Statement OCR intake (docs/RECEIPT_CAPTURE_SPEC.md §2). - * - * Each utility company mails 300+ paper bills a month, one per customer, which - * staff otherwise key in by hand through the "Captura" screen. Here they scan - * the stack, and the machine proposes customer + amount for every page; a - * human still confirms before anything reaches the ledger. - * - * One batch = one service kind, because the matcher is scoped per kind: a - * water account number and a phone number are compared against different - * columns, and mixing them in one upload is how a bill gets posted to the - * wrong customer. + * Same capture screen as `/estado-cuenta/lote`, opened on the automatic + * (scanned recibos + OCR) mode. Kept as its own route so links from a batch + * review page and older bookmarks land on the right tab. */ - -/** The kinds the parsers actually recognise today. */ -const SUPPORTED: ServiceKind[] = ["ELECTRIC", "WATER", "TELEPHONE"]; -/** Uploadable, but every page will land in review until a parser learns it. */ -const OTHER_KINDS: ServiceKind[] = ["GAS", "PROPERTY_TAX", "FEDERAL_ZONE", "CABLE"]; - -const STATUS_LABEL: Record = { - UPLOADED: "Recibido", - PROCESSING: "Procesando…", - READY_FOR_REVIEW: "Listo para revisar", - COMPLETED: "Registrado", - FAILED: "Falló", -}; - export default function RecibosPage() { return ( - + ); } - -function Recibos() { - const canIngest = useCan("statement:ingest"); - const [batches, setBatches] = useState([]); - const [ocrAvailable, setOcrAvailable] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - try { - const [list, status] = await Promise.all([ - listStatementBatches(), - getStatementStatus(), - ]); - setBatches(list.items); - setOcrAvailable(status.ocrAvailable); - setError(null); - } catch (e) { - setError((e as Error)?.message ?? "No se pudieron cargar los lotes."); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - // A batch of 300 pages takes minutes to OCR, so the list refreshes itself - // while anything is still working rather than making staff reload. - const working = batches.some( - (b) => b.status === "PROCESSING" || b.status === "UPLOADED", - ); - useEffect(() => { - if (!working) return; - const t = setInterval(() => void load(), 4000); - return () => clearInterval(t); - }, [working, load]); - - return ( -
-
-
-

Recibos (OCR)

-

- Escanea los recibos del mes y el sistema propone cliente e importe - para cada página. Nada se registra sin tu confirmación. -

-
-
- - {ocrAvailable === false && ( -
- Este servidor no tiene OCR instalado, así que no se pueden procesar - recibos. El resto del sistema funciona con normalidad. -
- )} - - {canIngest && ocrAvailable && } - - {error &&
{error}
} - -
-

- Lotes -

- {loading ? ( -
Cargando…
- ) : batches.length === 0 ? ( -
Todavía no hay lotes de recibos.
- ) : ( -
- - - - - - - - - - - - - {batches.map((b) => ( - - - - - - - - - - ))} - -
FechaServicioReferenciaEstadoPáginasSubido por -
{formatDate(b.createdAt)}{serviceKindLabel(b.serviceKind)}{b.label || "—"} - - {b.error && ( -
- {b.error} -
- )} -
{b._count?.documents ?? 0}{b.uploadedBy?.name ?? "—"} - - Revisar - -
-
- )} -
-
- ); -} - -function StatusTag({ status }: { status: StatementBatchStatus }) { - return {STATUS_LABEL[status] ?? status}; -} - -function UploadCard({ onDone }: { onDone: () => void }) { - const [files, setFiles] = useState([]); - const [serviceKind, setServiceKind] = useState("ELECTRIC"); - const [label, setLabel] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - async function submit() { - if (!files.length) return; - setBusy(true); - setError(null); - try { - await uploadStatementBatch(files, serviceKind, label.trim() || undefined); - setFiles([]); - setLabel(""); - onDone(); - } catch (e) { - setError((e as Error)?.message ?? "No se pudo subir el lote."); - } finally { - setBusy(false); - } - } - - const unsupported = !SUPPORTED.includes(serviceKind); - - return ( -
-

- Subir recibos escaneados -

-
- - - - - - - -
- - {unsupported && ( -
- Todavía no hay lectura automática para{" "} - {SERVICE_KIND_LABELS[serviceKind] ?? serviceKind}: cada página quedará - para revisión manual. Al confirmarlas se guarda el número de cuenta, - así que los recibos del mes siguiente sí se reconocerán solos. -
- )} - -

- Un lote es de un solo servicio. Cada página del PDF se trata como un - recibo distinto, salvo que el proveedor imprima varias hojas por cliente. -

-
- ); -} diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 40f18fe..575c243 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -24,7 +24,15 @@ import type { AuthUser, Ability } from "@/lib/types"; * abilities. Used by every authenticated page. */ -type NavLink = { href: string; label: string; ability?: Ability; exact?: boolean }; +type NavLink = { + href: string; + label: string; + ability?: Ability; + exact?: boolean; + /** Extra path prefixes that belong to this entry (e.g. a second route into + * the same screen), so they highlight it instead of nothing. */ + aliases?: string[]; +}; type NavEntry = | ({ kind: "link" } & NavLink) | { kind: "group"; label: string; items: NavLink[] }; @@ -45,11 +53,16 @@ const NAV: NavEntry[] = [ label: "Cobranza", items: [ // Daily data-entry screen (the legacy "Editor"). Hidden from VIEWER, who - // can't capture anyway — the page itself also refuses. - { href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" }, - // Same daily job as "Captura", entered from a stack of scanned bills - // instead of a keyboard. - { href: "/recibos", label: "Recibos (OCR)", ability: "statement:ingest" }, + // can't capture anyway — the page itself also refuses. Both capture modes + // live behind this one entry: keying receipts by hand, and scanning a + // stack of bills for OCR (the `/recibos` route opens the same screen on + // its automatic tab). + { + href: "/estado-cuenta/lote", + label: "Captura", + ability: "ledger:create", + aliases: ["/recibos"], + }, { href: "/estado-cuenta", label: "Estado de cuenta" }, { href: "/banco", label: "Chequera" }, ], @@ -101,9 +114,11 @@ function activeHref(pathname: string | null): string | null { if (!pathname) return null; let best: string | null = null; for (const item of NAV_LINKS) { + const under = (href: string) => + pathname === href || pathname.startsWith(`${href}/`); const match = item.exact ? pathname === item.href - : pathname === item.href || pathname.startsWith(`${item.href}/`); + : under(item.href) || (item.aliases?.some(under) ?? false); if (match && (best === null || item.href.length > best.length)) { best = item.href; } diff --git a/apps/web/src/components/Captura.tsx b/apps/web/src/components/Captura.tsx new file mode 100644 index 0000000..c925d23 --- /dev/null +++ b/apps/web/src/components/Captura.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ManualCheckCapture } from "@/components/ManualCheckCapture"; +import { StatementIntake } from "@/components/StatementIntake"; +import { useCan } from "@/lib/abilities"; + +/** + * The daily capture screen (the legacy "Editor"), with two ways in: + * + * - **manual** — key each customer's receipt against one check by hand. + * - **auto** — scan the stack of paper bills and let OCR propose customer and + * amount for every page, which a human still confirms. + * + * Both end in the same place: charges on the customer's ledger, posted against + * one check. They are modes of one screen rather than two menu entries because + * it is one job — staff pick the mode by what's on the desk that morning, a + * stack of bills or a keyboard. + * + * `/estado-cuenta/lote` opens on manual, `/recibos` opens on auto; both render + * this component, so an old bookmark still lands on the right tab. + */ + +export type CaptureMode = "manual" | "auto"; + +const MODE_HINT: Record = { + manual: + "Captura los recibos de varios clientes contra un mismo cheque y concilia el total antes de guardar.", + auto: "Escanea los recibos del mes y el sistema propone cliente e importe para cada página. Nada se registra sin tu confirmación.", +}; + +export function Captura({ initialMode = "manual" }: { initialMode?: CaptureMode }) { + const canCapture = useCan("ledger:create"); + const canIngest = useCan("statement:ingest"); + + // Gating is cosmetic (the API enforces every write), but a user who only has + // one of the two abilities should land on the mode they can actually use. + const modes: { key: CaptureMode; label: string }[] = [ + ...(canCapture ? [{ key: "manual" as const, label: "Captura manual" }] : []), + ...(canIngest + ? [{ key: "auto" as const, label: "Captura automática (OCR)" }] + : []), + ]; + + const [mode, setMode] = useState( + modes.some((m) => m.key === initialMode) ? initialMode : (modes[0]?.key ?? "manual"), + ); + + if (modes.length === 0) { + return ( +
+ No tienes permiso para capturar movimientos. +
+ ); + } + + return ( + <> +
+
+

Captura

+

{MODE_HINT[mode]}

+
+ + Volver a estado de cuenta + +
+ + {modes.length > 1 && ( +
+ {modes.map((m) => ( + + ))} +
+ )} + + {mode === "manual" ? : } + + ); +} diff --git a/apps/web/src/components/ManualCheckCapture.tsx b/apps/web/src/components/ManualCheckCapture.tsx new file mode 100644 index 0000000..5e2944c --- /dev/null +++ b/apps/web/src/components/ManualCheckCapture.tsx @@ -0,0 +1,526 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { CustomerPicker } from "@/components/CustomerPicker"; +import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api"; +import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels"; +import type { + BatchCreateInput, + BillingFacets, + ByCheckResponse, + Currency, + LedgerCurrency, + TransactionDomain, +} from "@/lib/types"; + +/** + * Batch capture by check — the "Editor" screen from the legacy system + * (docs/RECEIPT_CAPTURE_SPEC.md §1.2), and the manual half of the Captura + * screen (see `Captura`). + * + * Staff key many customers' receipts against ONE physical check before cutting + * it, then check that the captured total matches the check's amount. That + * reconciliation is the whole point, so the running total is the most prominent + * thing on the page and an optional "importe del cheque" field turns it into a + * live difference. + * + * No batch entity is persisted: `checkNumber` is a plain column, and grouping + * by it answers every by-check question (see the "Reporte por cheque" report). + */ + +const DOMAINS: { key: TransactionDomain; label: string }[] = [ + { key: "UTILITY", label: "Servicios" }, + { key: "INSURANCE", label: "Seguros" }, + { key: "TRUST", label: "Fideicomiso" }, +]; + +interface Line { + /** Local row key — lines have no server identity until the batch posts. */ + key: number; + customerId: string; + customerName: string; + amount: string; + reference: string; + period: string; + outstanding: boolean; +} + +function blankLine(key: number): Line { + return { + key, + customerId: "", + customerName: "", + amount: "", + reference: "", + period: "", + outstanding: false, + }; +} + +export function ManualCheckCapture() { + const [facets, setFacets] = useState(null); + + // Check-level fields — shared by every line. + const [domain, setDomain] = useState("UTILITY"); + const [currency, setCurrency] = useState("MXN"); + const [typeId, setTypeId] = useState(""); + const [checkNumber, setCheckNumber] = useState(""); + const [transactionDate, setTransactionDate] = useState( + new Date().toISOString().slice(0, 10), + ); + /** The physical check's amount, for reconciliation only — never submitted. */ + const [checkAmount, setCheckAmount] = useState(""); + + const [lines, setLines] = useState([blankLine(1), blankLine(2), blankLine(3)]); + const [nextKey, setNextKey] = useState(4); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [posted, setPosted] = useState(null); + + useEffect(() => { + getBillingFacets().then(setFacets).catch(() => setFacets(null)); + }, []); + + const filled = lines.filter( + (l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)), + ); + + // Charges are captured as positive numbers and signed on submit, matching + // MovementForm — staff type what's on the bill, not a negative. + const total = useMemo( + () => + filled + .filter((l) => !l.outstanding) + .reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0), + [filled], + ); + const outstandingTotal = useMemo( + () => + filled + .filter((l) => l.outstanding) + .reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0), + [filled], + ); + + const checkAmt = Number(checkAmount); + const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt); + const diff = hasCheckAmt ? checkAmt - total : 0; + const reconciled = hasCheckAmt && Math.abs(diff) < 0.005; + + function update(key: number, patch: Partial) { + setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l))); + } + + function addLine() { + setLines((ls) => [...ls, blankLine(nextKey)]); + setNextKey((k) => k + 1); + } + + function removeLine(key: number) { + setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key))); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!checkNumber.trim()) { + setError("Indica el número de cheque."); + return; + } + if (filled.length === 0) { + setError("Captura al menos una línea con cliente y monto."); + return; + } + const dupes = filled + .map((l) => l.customerId) + .filter((id, i, arr) => arr.indexOf(id) !== i); + if (dupes.length) { + const names = filled + .filter((l) => dupes.includes(l.customerId)) + .map((l) => l.customerName); + if ( + !window.confirm( + `Hay más de una línea para el mismo cliente (${[...new Set(names)].join( + ", ", + )}). ¿Continuar?`, + ) + ) + return; + } + + const payload: BatchCreateInput = { + domain, + transactionDate, + checkNumber: checkNumber.trim(), + currency: currency as Currency, + typeId: typeId || undefined, + lines: filled.map((l) => ({ + customerId: l.customerId, + // Every line of a check batch is a charge the office paid out. + amount: -Math.abs(Number(l.amount)), + reference: l.reference.trim() || undefined, + period: l.period.trim() || undefined, + outstanding: l.outstanding || undefined, + })), + }; + + setSaving(true); + setError(null); + try { + await createMovementBatch(payload); + // Re-read through the by-check view so the confirmation shows what's + // actually stored (including anything captured against this check + // earlier), not just what this request sent. + setPosted(await getByCheck(payload.checkNumber)); + } catch (e2) { + setError((e2 as Error)?.message ?? "No se pudo guardar el lote."); + } finally { + setSaving(false); + } + } + + function reset() { + setPosted(null); + setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]); + setNextKey((k) => k + 3); + setCheckNumber(""); + setCheckAmount(""); + } + + if (posted) { + return ( + <> +
+
+

Lote capturado

+

+ Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "} + {posted.count === 1 ? "movimiento" : "movimientos"} +

+
+
+ + + Volver a estado de cuenta + +
+
+ +
+ {posted.totals.map((t) => ( +
+ {t.currency} + + Total del cheque {formatMoney(t.total, t.currency)} + + {formatNumber(t.count)} movimientos +
+ ))} + {posted.outstandingCount > 0 && ( +
+ + {formatNumber(posted.outstandingCount)} sin fondos (no suman al + total) + +
+ )} +
+ +
+ + + + + + + + + + + + {posted.items.map((i) => ( + + + + + + + + ))} + +
ClienteReferenciaPeriodoEstadoMonto
+ + {i.customerName} + + {i.reference || "—"}{i.period || "—"}{i.outstanding ? "Sin fondos" : "Pagado"} + + {formatMoney(i.amount, i.currency)} + +
+
+ +

+ Para imprimir la conciliación, usa el reporte{" "} + + Reporte por cheque + + . +

+ + ); + } + + return ( + <> + {error &&
{error}
} + +
+
+

+ Datos del cheque +

+
+ + + + + + +
+
+ +
+
+

+ Recibos ({formatNumber(filled.length)}) +

+ +
+ +
+ + + + + + + + + + + + {lines.map((l) => ( + + + + + + + + + ))} + +
Cliente *ReferenciaPeriodo + Monto * + Sin fondos +
+ + update(l.key, { customerId: id, customerName: name }) + } + /> + + + update(l.key, { reference: e.target.value }) + } + /> + + update(l.key, { period: e.target.value })} + placeholder="2026-07" + /> + + update(l.key, { amount: e.target.value })} + placeholder="0.00" + /> + + + update(l.key, { outstanding: e.target.checked }) + } + aria-label="Sin fondos" + /> + + +
+
+
+ +
+

+ Conciliación +

+
+
+ {currency} + + Capturado {formatMoney(String(-total), currency)} + + {formatNumber(filled.filter((l) => !l.outstanding).length)} recibos +
+ {outstandingTotal > 0 && ( +
+ + Sin fondos{" "} + {formatMoney(String(-outstandingTotal), currency)}{" "} + (no suma al cheque) + +
+ )} + {hasCheckAmt && ( +
+ + {reconciled ? ( + Cuadra con el cheque + ) : ( + <> + Diferencia{" "} + + {formatMoney(String(diff), currency)} + + + )} + +
+ )} +
+
+ +
+ + + Cancelar + +
+
+ + ); +} diff --git a/apps/web/src/components/StatementIntake.tsx b/apps/web/src/components/StatementIntake.tsx new file mode 100644 index 0000000..0404fb5 --- /dev/null +++ b/apps/web/src/components/StatementIntake.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { + getStatementStatus, + listStatementBatches, + uploadStatementBatch, +} from "@/lib/api"; +import { useCan } from "@/lib/abilities"; +import { formatDate, SERVICE_KIND_LABELS, serviceKindLabel } from "@/lib/labels"; +import type { ServiceKind, StatementBatch, StatementBatchStatus } from "@/lib/types"; + +/** + * Automatic capture — statement OCR intake (docs/RECEIPT_CAPTURE_SPEC.md §2). + * + * Each utility company mails 300+ paper bills a month, one per customer, which + * staff otherwise key in by hand on the manual tab of the same "Captura" + * screen. Here they scan the stack instead, and the machine proposes customer + + * amount for every page; a human still confirms before anything reaches the + * ledger. Same daily job, same ledger path — only the input differs, which is + * why it lives as a mode of Captura rather than a screen of its own. + * + * One batch = one service kind, because the matcher is scoped per kind: a + * water account number and a phone number are compared against different + * columns, and mixing them in one upload is how a bill gets posted to the + * wrong customer. + */ + +/** The kinds the parsers actually recognise today. */ +const SUPPORTED: ServiceKind[] = ["ELECTRIC", "WATER", "TELEPHONE"]; +/** Uploadable, but every page will land in review until a parser learns it. */ +const OTHER_KINDS: ServiceKind[] = ["GAS", "PROPERTY_TAX", "FEDERAL_ZONE", "CABLE"]; + +const STATUS_LABEL: Record = { + UPLOADED: "Recibido", + PROCESSING: "Procesando…", + READY_FOR_REVIEW: "Listo para revisar", + COMPLETED: "Registrado", + FAILED: "Falló", +}; + +export function StatementIntake() { + const canIngest = useCan("statement:ingest"); + const [batches, setBatches] = useState([]); + const [ocrAvailable, setOcrAvailable] = useState(null); + const [storageAvailable, setStorageAvailable] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + const [list, status] = await Promise.all([ + listStatementBatches(), + getStatementStatus(), + ]); + setBatches(list.items); + setOcrAvailable(status.ocrAvailable); + setStorageAvailable(status.storageAvailable); + setError(null); + } catch (e) { + setError((e as Error)?.message ?? "No se pudieron cargar los lotes."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + // A batch of 300 pages takes minutes to OCR, so the list refreshes itself + // while anything is still working rather than making staff reload. + const working = batches.some( + (b) => b.status === "PROCESSING" || b.status === "UPLOADED", + ); + useEffect(() => { + if (!working) return; + const t = setInterval(() => void load(), 4000); + return () => clearInterval(t); + }, [working, load]); + + const ready = ocrAvailable === true && storageAvailable === true; + + return ( +
+ {ocrAvailable === false && ( +
+ Este servidor no tiene OCR instalado, así que no se pueden leer recibos + escaneados. Usa la captura manual; el resto del sistema funciona con + normalidad. +
+ )} + + {storageAvailable === false && ( +
+ Este servidor no tiene configurado el almacenamiento de documentos, así + que no hay dónde guardar los recibos escaneados. Usa la captura manual + mientras se configura. +
+ )} + + {canIngest && ready && } + + {error &&
{error}
} + +
+

+ Lotes +

+ {loading ? ( +
Cargando…
+ ) : batches.length === 0 ? ( +
Todavía no hay lotes de recibos.
+ ) : ( +
+ + + + + + + + + + + + + {batches.map((b) => ( + + + + + + + + + + ))} + +
FechaServicioReferenciaEstadoPáginasSubido por +
{formatDate(b.createdAt)}{serviceKindLabel(b.serviceKind)}{b.label || "—"} + + {b.error && ( +
+ {b.error} +
+ )} +
{b._count?.documents ?? 0}{b.uploadedBy?.name ?? "—"} + + Revisar + +
+
+ )} +
+
+ ); +} + +function StatusTag({ status }: { status: StatementBatchStatus }) { + return {STATUS_LABEL[status] ?? status}; +} + +function UploadCard({ onDone }: { onDone: () => void }) { + const [files, setFiles] = useState([]); + const [serviceKind, setServiceKind] = useState("ELECTRIC"); + const [label, setLabel] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function submit() { + if (!files.length) return; + setBusy(true); + setError(null); + try { + await uploadStatementBatch(files, serviceKind, label.trim() || undefined); + setFiles([]); + setLabel(""); + onDone(); + } catch (e) { + setError((e as Error)?.message ?? "No se pudo subir el lote."); + } finally { + setBusy(false); + } + } + + const unsupported = !SUPPORTED.includes(serviceKind); + + return ( +
+

+ Subir recibos escaneados +

+
+ + + + + + + +
+ + {error && ( +
+ {error} +
+ )} + + {unsupported && ( +
+ Todavía no hay lectura automática para{" "} + {SERVICE_KIND_LABELS[serviceKind] ?? serviceKind}: cada página quedará + para revisión manual. Al confirmarlas se guarda el número de cuenta, + así que los recibos del mes siguiente sí se reconocerán solos. +
+ )} + +

+ Un lote es de un solo servicio. Cada página del PDF se trata como un + recibo distinto, salvo que el proveedor imprima varias hojas por cliente. +

+
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 75be613..dc9bb2f 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -905,8 +905,14 @@ export function reportDownloadUrl( /* ------------------------------------- Statement OCR intake (recibos) */ -/** Whether this deployment has the OCR binaries — upload is hidden without. */ -export function getStatementStatus(): Promise<{ ocrAvailable: boolean }> { +/** + * Whether this deployment can ingest scans — automatic capture is hidden + * without it. OCR reads the page, object storage keeps it; both are required. + */ +export function getStatementStatus(): Promise<{ + ocrAvailable: boolean; + storageAvailable: boolean; +}> { return apiFetch("/statements/status"); } diff --git a/docs/RECEIPT_CAPTURE_SPEC.md b/docs/RECEIPT_CAPTURE_SPEC.md index 5486a74..c21e065 100644 --- a/docs/RECEIPT_CAPTURE_SPEC.md +++ b/docs/RECEIPT_CAPTURE_SPEC.md @@ -136,9 +136,19 @@ single-movement form. > `OcrProvider` seam with a self-hosted Tesseract implementation, per-provider > parsers for CFE / CESPT / Telnor, a scoped matcher, and a review queue that > posts through `BillingService.createBatch` with `source: "OCR"`. Web: -> `/recibos` (upload + batch list) and `/recibos/:id` (review queue with the -> page image beside the extracted fields). New abilities `statement:ingest` / -> `statement:review`, both STAFF. +> the "Captura automática (OCR)" tab of the Captura screen (upload + batch +> list) and `/recibos/:id` (review queue with the page image beside the +> extracted fields). New abilities `statement:ingest` / `statement:review`, both +> STAFF. +> +> Auto-capture is a *mode of* §1.2's capture screen, not a separate menu entry: +> it is the same daily job with a scanner instead of a keyboard, and both modes +> post through the same ledger path. `/estado-cuenta/lote` opens the manual tab, +> `/recibos` the automatic one; both render `components/Captura.tsx`. +> +> Requires object storage (`S3_ENDPOINT` + credentials): the scans are kept as +> blobs. `GET /statements/status` reports `ocrAvailable` and `storageAvailable`, +> and the upload card hides itself unless both hold. > > **Measured, not assumed.** Ten real scans (46 pages of CFE, CESPT and Telnor > bills) drove every decision below. Against them the shipped parser identifies