feat(statements): OCR intake for scanned utility bills
Staff key 300+ utility statements per company per month by hand. This adds the ingest -> split -> OCR -> match -> review pipeline that proposes customer and amount per page instead (RECEIPT_CAPTURE_SPEC §2), posting through the existing BillingService.createBatch seam with source=OCR and a per-document captureRef so machine and hand capture share one write path and audit trail. Everything was designed against 10 real scanned statements (46 pages of CFE, CESPT and Telnor bills) rather than from the sample-free spec. The scans have no text layer at all — they are camera images — so OCR is mandatory, and they arrive bundled one customer per page. Measured on those pages the parser identifies the provider 46/46 and reads an account reference 43/46; against the dev database that is 39/46 (85%) exact auto-match, 40/46 identified, with the rest genuine review cases. That closes the OCR-provider question in favour of self-hosted Tesseract: it clears the bar for a queue where a human confirms every row, and OcrProvider keeps a managed API a one-line swap. The samples corrected three things the spec had wrong or unknown: - Clave catastral is NOT predial. DATMEX.clave (934 rows) is what CESPT and predial bills print; DATMEX.predial, which PROPERTY_TAX.accountNumber holds, has 663 distinct values across 1135 rows and appears on no statement. The clave now lives on Property.cadastralKey as the matcher's secondary key; predial is left untouched. This had been blocking predial matching. - Gas was recoverable: 160 of 334 DATMEX.gas values are real account numbers (the rest are ESTACIONARIO/CILINDRO descriptors), now in GAS.meterNumber. - Phone is one billed line per property (534/18/1 across phone1/2/3), so the new TELEPHONE ServiceKind backfills from phone1 only, not three rows. Matching is scoped to one column per service kind and never reads the customer name — a CESPT receipt prints ARNAIZ ROSAS ELSA AURORA for an account this office holds under CATT, RANDY, because the printed name is the registrant, not the current owner. Where a provider prints a payment barcode it beats the printed label (one CFE label OCR'd a digit too many while its barcode was correct) and the two cross-check, with disagreement forcing review. Confirming a document whose service had no reference writes it back, so gas and any other cold start is a one-time cost rather than a permanent queue. Verified end to end against the live dev API and MinIO: real scans uploaded over HTTP, matched, confirmed against a check, and the resulting rows checked in MySQL (negative amounts, captureSource=OCR, concept derived from the batch kind, captureRef linking back to each page). Re-confirming a posted batch is refused. Test data was removed afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import {
|
||||
confirmStatementBatch,
|
||||
getStatementBatch,
|
||||
listStatementDocuments,
|
||||
rejectStatementDocument,
|
||||
reviewStatementDocument,
|
||||
statementPageUrl,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
|
||||
import type {
|
||||
ConfirmBatchInput,
|
||||
StatementBatchDetail,
|
||||
StatementDocument,
|
||||
StatementDocumentStatus,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Review queue for one batch of scanned bills.
|
||||
*
|
||||
* The reviewer's job is to answer one question per page — "is this the right
|
||||
* customer for this amount?" — so the page image sits next to the extracted
|
||||
* fields and every row can be corrected in place. Rows the matcher is sure
|
||||
* about are pre-approved and can be posted in bulk; everything else is listed
|
||||
* first, because that is the work.
|
||||
*/
|
||||
|
||||
const STATUS_LABEL: Record<StatementDocumentStatus, string> = {
|
||||
PENDING_OCR: "En proceso",
|
||||
OCR_FAILED: "No se pudo leer",
|
||||
NEEDS_REVIEW: "Requiere revisión",
|
||||
MATCHED: "Identificado",
|
||||
CONFIRMED: "Confirmado",
|
||||
POSTED: "Registrado",
|
||||
REJECTED: "Descartado",
|
||||
};
|
||||
|
||||
/** Rows still needing a decision, listed before the settled ones. */
|
||||
const OPEN_FIRST: StatementDocumentStatus[] = [
|
||||
"NEEDS_REVIEW",
|
||||
"OCR_FAILED",
|
||||
"MATCHED",
|
||||
"CONFIRMED",
|
||||
"POSTED",
|
||||
"REJECTED",
|
||||
"PENDING_OCR",
|
||||
];
|
||||
|
||||
export default function RecibosBatchPage({ params }: { params: { id: string } }) {
|
||||
return (
|
||||
<AppShell>
|
||||
<BatchReview id={params.id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchReview({ id }: { id: string }) {
|
||||
const canReview = useCan("statement:review");
|
||||
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
|
||||
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [b, d] = await Promise.all([
|
||||
getStatementBatch(id),
|
||||
listStatementDocuments(id),
|
||||
]);
|
||||
setBatch(b);
|
||||
setDocs(d);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo cargar el lote.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const processing = batch?.status === "PROCESSING" || batch?.status === "UPLOADED";
|
||||
useEffect(() => {
|
||||
if (!processing) return;
|
||||
const t = setInterval(() => void load(), 4000);
|
||||
return () => clearInterval(t);
|
||||
}, [processing, load]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...docs].sort(
|
||||
(a, b) =>
|
||||
OPEN_FIRST.indexOf(a.status) - OPEN_FIRST.indexOf(b.status) ||
|
||||
a.pageNumber - b.pageNumber,
|
||||
),
|
||||
[docs],
|
||||
);
|
||||
|
||||
const readyCount = docs.filter(
|
||||
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
||||
).length;
|
||||
|
||||
if (loading) return <div className="state-box">Cargando…</div>;
|
||||
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">
|
||||
Recibos — {serviceKindLabel(batch.serviceKind)}
|
||||
{batch.label ? ` · ${batch.label}` : ""}
|
||||
</h1>
|
||||
<p className="page-sub">
|
||||
{formatDate(batch.createdAt)} · {docs.length} página(s) ·{" "}
|
||||
{STATUS_LABEL_BATCH[batch.status] ?? batch.status}
|
||||
</p>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" href="/recibos">
|
||||
Volver
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
{processing && (
|
||||
<div className="state-box">
|
||||
Leyendo los recibos… esta pantalla se actualiza sola.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SummaryCard batch={batch} readyCount={readyCount} />
|
||||
|
||||
{canReview && readyCount > 0 && (
|
||||
<ConfirmCard
|
||||
batchId={id}
|
||||
readyCount={readyCount}
|
||||
onDone={load}
|
||||
setError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section className="stack">
|
||||
{sorted.map((doc) => (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
canReview={canReview}
|
||||
onChange={load}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_LABEL_BATCH: Record<string, string> = {
|
||||
UPLOADED: "Recibido",
|
||||
PROCESSING: "Procesando",
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Registrado",
|
||||
FAILED: "Falló",
|
||||
};
|
||||
|
||||
function SummaryCard({
|
||||
batch,
|
||||
readyCount,
|
||||
}: {
|
||||
batch: StatementBatchDetail;
|
||||
readyCount: number;
|
||||
}) {
|
||||
const entries = Object.entries(batch.byStatus) as [StatementDocumentStatus, number][];
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 20 }}>
|
||||
{entries.map(([status, count]) => (
|
||||
<div key={status}>
|
||||
<div className="page-sub">{STATUS_LABEL[status] ?? status}</div>
|
||||
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{count}</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<div className="page-sub">Importe pendiente</div>
|
||||
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>
|
||||
{formatMoney(batch.pendingTotal, "MXN")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="page-sub">Listos para registrar</div>
|
||||
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{readyCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posting is by check, exactly as on the manual capture screen — an OCR batch
|
||||
* is still "these bills, paid with this check", so the same fields are asked
|
||||
* for and the same ledger path is used.
|
||||
*/
|
||||
function ConfirmCard({
|
||||
batchId,
|
||||
readyCount,
|
||||
onDone,
|
||||
setError,
|
||||
}: {
|
||||
batchId: string;
|
||||
readyCount: number;
|
||||
onDone: () => void;
|
||||
setError: (m: string | null) => void;
|
||||
}) {
|
||||
const [checkNumber, setCheckNumber] = useState("");
|
||||
const [transactionDate, setTransactionDate] = useState(
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
const [outstanding, setOutstanding] = useState(false);
|
||||
const [includeReviewed, setIncludeReviewed] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
if (!checkNumber.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const input: ConfirmBatchInput = {
|
||||
checkNumber: checkNumber.trim(),
|
||||
transactionDate,
|
||||
outstanding,
|
||||
includeReviewed,
|
||||
};
|
||||
const r = await confirmStatementBatch(batchId, input);
|
||||
setResult(
|
||||
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}.`,
|
||||
);
|
||||
setCheckNumber("");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Registrar {readyCount} recibo(s)
|
||||
</h2>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Cheque</span>
|
||||
<input
|
||||
className="input"
|
||||
value={checkNumber}
|
||||
onChange={(e) => setCheckNumber(e.target.value)}
|
||||
placeholder="Número de cheque"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="page-sub">Fecha</span>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={transactionDate}
|
||||
onChange={(e) => setTransactionDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={outstanding}
|
||||
onChange={(e) => setOutstanding(e.target.checked)}
|
||||
/>{" "}
|
||||
Sin fondos (queda pendiente)
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeReviewed}
|
||||
onChange={(e) => setIncludeReviewed(e.target.checked)}
|
||||
/>{" "}
|
||||
Incluir los confirmados a mano
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!checkNumber.trim() || busy}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? "Registrando…" : "Registrar"}
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<div className="state-box" style={{ marginTop: 12 }}>
|
||||
{result}
|
||||
</div>
|
||||
)}
|
||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||
Se registran como cargos del cliente, por la misma vía que la captura
|
||||
manual. Un lote registrado dos veces no duplica cobros.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentRow({
|
||||
doc,
|
||||
canReview,
|
||||
onChange,
|
||||
}: {
|
||||
doc: StatementDocument;
|
||||
canReview: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(
|
||||
doc.status === "NEEDS_REVIEW" || doc.status === "OCR_FAILED",
|
||||
);
|
||||
const [amount, setAmount] = useState(doc.extractedAmount ?? "");
|
||||
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
|
||||
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
|
||||
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const settled = doc.status === "POSTED" || doc.status === "REJECTED";
|
||||
|
||||
async function save(status: "MATCHED" | "CONFIRMED") {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await reviewStatementDocument(doc.id, {
|
||||
accountRef: accountRef.trim() || undefined,
|
||||
amount: amount ? Number(amount) : undefined,
|
||||
matchedCustomerId: customerId || undefined,
|
||||
status,
|
||||
});
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? "No se pudo guardar.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await rejectStatementDocument(doc.id);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? "No se pudo descartar.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<div
|
||||
className="inline-form"
|
||||
style={{ justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}
|
||||
>
|
||||
<div>
|
||||
<strong>Página {doc.pageNumber}</strong>{" "}
|
||||
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
|
||||
{doc.provider && <span className="page-sub">{doc.provider}</span>}
|
||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||
{doc.matchedCustomer ? (
|
||||
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
|
||||
{doc.matchedCustomer.name}
|
||||
</Link>
|
||||
) : (
|
||||
"Sin cliente asignado"
|
||||
)}
|
||||
{doc.extractedAccountRef && ` · cuenta ${doc.extractedAccountRef}`}
|
||||
{doc.extractedCadastralKey && ` · clave ${doc.extractedCadastralKey}`}
|
||||
</div>
|
||||
{doc.matchNote && (
|
||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||
{doc.matchNote}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-form" style={{ gap: 8 }}>
|
||||
<strong>
|
||||
{doc.extractedAmount
|
||||
? formatMoney(doc.extractedAmount, "MXN")
|
||||
: "sin importe"}
|
||||
</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? "Ocultar" : "Ver recibo"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{ marginTop: 12, display: "grid", gap: 16 }}>
|
||||
{/* The scan itself — the reviewer's source of truth, not the OCR. */}
|
||||
<img
|
||||
src={statementPageUrl(doc.id)}
|
||||
alt={`Recibo página ${doc.pageNumber}`}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
border: "1px solid var(--border, #ddd)",
|
||||
borderRadius: 6,
|
||||
}}
|
||||
/>
|
||||
|
||||
{canReview && !settled && (
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Cuenta</span>
|
||||
<input
|
||||
className="input"
|
||||
value={accountRef}
|
||||
onChange={(e) => setAccountRef(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="page-sub">Importe</span>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ minWidth: 260 }}>
|
||||
<span className="page-sub">Cliente</span>
|
||||
<CustomerPicker
|
||||
value={customerId}
|
||||
valueName={customerName}
|
||||
onPick={(cid, name) => {
|
||||
setCustomerId(cid);
|
||||
setCustomerName(name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !customerId}
|
||||
onClick={() => save("MATCHED")}
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={busy}
|
||||
onClick={reject}
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div className="state-box state-error">{err}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"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";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** 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<StatementBatchStatus, string> = {
|
||||
UPLOADED: "Recibido",
|
||||
PROCESSING: "Procesando…",
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Registrado",
|
||||
FAILED: "Falló",
|
||||
};
|
||||
|
||||
export default function RecibosPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Recibos />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Recibos() {
|
||||
const canIngest = useCan("statement:ingest");
|
||||
const [batches, setBatches] = useState<StatementBatch[]>([]);
|
||||
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="stack">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Recibos (OCR)</h1>
|
||||
<p className="page-sub">
|
||||
Escanea los recibos del mes y el sistema propone cliente e importe
|
||||
para cada página. Nada se registra sin tu confirmación.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{ocrAvailable === false && (
|
||||
<div className="state-box state-error">
|
||||
Este servidor no tiene OCR instalado, así que no se pueden procesar
|
||||
recibos. El resto del sistema funciona con normalidad.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canIngest && ocrAvailable && <UploadCard onDone={load} />}
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Lotes
|
||||
</h2>
|
||||
{loading ? (
|
||||
<div className="state-box">Cargando…</div>
|
||||
) : batches.length === 0 ? (
|
||||
<div className="state-box">Todavía no hay lotes de recibos.</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Servicio</th>
|
||||
<th>Referencia</th>
|
||||
<th>Estado</th>
|
||||
<th className="num">Páginas</th>
|
||||
<th>Subido por</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td style={{ whiteSpace: "nowrap" }}>{formatDate(b.createdAt)}</td>
|
||||
<td>{serviceKindLabel(b.serviceKind)}</td>
|
||||
<td>{b.label || "—"}</td>
|
||||
<td>
|
||||
<StatusTag status={b.status} />
|
||||
{b.error && (
|
||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||
{b.error}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="num">{b._count?.documents ?? 0}</td>
|
||||
<td>{b.uploadedBy?.name ?? "—"}</td>
|
||||
<td>
|
||||
<Link className="btn btn-ghost" href={`/recibos/${b.id}`}>
|
||||
Revisar
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: StatementBatchStatus }) {
|
||||
return <span className="tag">{STATUS_LABEL[status] ?? status}</span>;
|
||||
}
|
||||
|
||||
function UploadCard({ onDone }: { onDone: () => void }) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [serviceKind, setServiceKind] = useState<ServiceKind>("ELECTRIC");
|
||||
const [label, setLabel] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Subir recibos escaneados
|
||||
</h2>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Servicio</span>
|
||||
<select
|
||||
className="input"
|
||||
value={serviceKind}
|
||||
onChange={(e) => setServiceKind(e.target.value as ServiceKind)}
|
||||
>
|
||||
<optgroup label="Con lectura automática">
|
||||
{SUPPORTED.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{SERVICE_KIND_LABELS[k] ?? k}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Sin lectura automática (revisión manual)">
|
||||
{OTHER_KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{SERVICE_KIND_LABELS[k] ?? k}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="page-sub">Referencia (opcional)</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="ej. CFE julio 2026"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="page-sub">Archivos PDF</span>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!files.length || busy}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? "Subiendo…" : `Procesar ${files.length || ""}`.trim()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{unsupported && (
|
||||
<div className="state-box" style={{ marginTop: 12 }}>
|
||||
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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user