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