feat(captura): fold recibo OCR into Captura as an automatic mode
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
<AppShell>
|
||||
<BatchCapture />
|
||||
<Captura initialMode="manual" />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchCapture() {
|
||||
const canCapture = useCan("ledger:create");
|
||||
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
||||
|
||||
// Check-level fields — shared by every line.
|
||||
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
|
||||
const [currency, setCurrency] = useState<LedgerCurrency>("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<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
|
||||
const [nextKey, setNextKey] = useState(4);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [posted, setPosted] = useState<ByCheckResponse | null>(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<Line>) {
|
||||
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 (
|
||||
<div className="state-box state-error">
|
||||
No tienes permiso para capturar movimientos.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (posted) {
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Lote capturado</h1>
|
||||
<p className="eyebrow">
|
||||
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
|
||||
{posted.count === 1 ? "movimiento" : "movimientos"}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={reset}>
|
||||
Capturar otro cheque
|
||||
</button>
|
||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||
Volver a estado de cuenta
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filtered-totals" style={{ marginBottom: 16 }}>
|
||||
{posted.totals.map((t) => (
|
||||
<div className="filtered-total" key={t.currency}>
|
||||
<span className="filtered-total-cur">{t.currency}</span>
|
||||
<span className="filtered-total-net">
|
||||
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
|
||||
</span>
|
||||
<span>{formatNumber(t.count)} movimientos</span>
|
||||
</div>
|
||||
))}
|
||||
{posted.outstandingCount > 0 && (
|
||||
<div className="filtered-total">
|
||||
<span>
|
||||
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
|
||||
total)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cliente</th>
|
||||
<th>Referencia</th>
|
||||
<th>Periodo</th>
|
||||
<th>Estado</th>
|
||||
<th className="num">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posted.items.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<td>
|
||||
<Link
|
||||
href={`/estado-cuenta/${i.customerId}`}
|
||||
className="inline-link"
|
||||
>
|
||||
{i.customerName}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{i.reference || "—"}</td>
|
||||
<td>{i.period || "—"}</td>
|
||||
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
|
||||
<td className="num">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(i.amount, i.currency)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="muted" style={{ marginTop: 14 }}>
|
||||
Para imprimir la conciliación, usa el reporte{" "}
|
||||
<Link
|
||||
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
|
||||
posted.checkNumber,
|
||||
)}`}
|
||||
className="inline-link"
|
||||
>
|
||||
Reporte por cheque
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Captura por cheque</h1>
|
||||
<p className="eyebrow">
|
||||
Captura los recibos de varios clientes contra un mismo cheque y
|
||||
concilia el total antes de guardar.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||
Cancelar
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
||||
Datos del cheque
|
||||
</h2>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Número de cheque *</span>
|
||||
<input
|
||||
className="input"
|
||||
value={checkNumber}
|
||||
onChange={(e) => setCheckNumber(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Fecha *</span>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
required
|
||||
value={transactionDate}
|
||||
onChange={(e) => setTransactionDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Línea de negocio *</span>
|
||||
<select
|
||||
className="select"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
|
||||
>
|
||||
{DOMAINS.map((d) => (
|
||||
<option key={d.key} value={d.key}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Moneda *</span>
|
||||
<select
|
||||
className="select"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
||||
>
|
||||
<option value="MXN">Pesos (MXN)</option>
|
||||
<option value="USD">Dólares (USD)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Concepto</span>
|
||||
<select
|
||||
className="select"
|
||||
value={typeId}
|
||||
onChange={(e) => setTypeId(e.target.value)}
|
||||
>
|
||||
<option value="">(sin concepto)</option>
|
||||
{facets?.types.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{txTypeLabel({ nameEn: t.name })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Importe del cheque</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={checkAmount}
|
||||
onChange={(e) => setCheckAmount(e.target.value)}
|
||||
placeholder="Para conciliar"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
Recibos ({formatNumber(filled.length)})
|
||||
</h2>
|
||||
<button type="button" className="btn btn-outline" onClick={addLine}>
|
||||
Agregar línea
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ minWidth: 240 }}>Cliente *</th>
|
||||
<th style={{ minWidth: 120 }}>Referencia</th>
|
||||
<th style={{ minWidth: 100 }}>Periodo</th>
|
||||
<th style={{ minWidth: 110 }} className="num">
|
||||
Monto *
|
||||
</th>
|
||||
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
|
||||
<th style={{ width: 1 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((l) => (
|
||||
<tr key={l.key}>
|
||||
<td>
|
||||
<CustomerPicker
|
||||
value={l.customerId}
|
||||
valueName={l.customerId ? l.customerName : undefined}
|
||||
onPick={(id, name) =>
|
||||
update(l.key, { customerId: id, customerName: name })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input"
|
||||
value={l.reference}
|
||||
onChange={(e) =>
|
||||
update(l.key, { reference: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input"
|
||||
value={l.period}
|
||||
onChange={(e) => update(l.key, { period: e.target.value })}
|
||||
placeholder="2026-07"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input num"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={l.amount}
|
||||
onChange={(e) => update(l.key, { amount: e.target.value })}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</td>
|
||||
<td style={{ textAlign: "center" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={l.outstanding}
|
||||
onChange={(e) =>
|
||||
update(l.key, { outstanding: e.target.checked })
|
||||
}
|
||||
aria-label="Sin fondos"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 10px", fontSize: 12 }}
|
||||
onClick={() => removeLine(l.key)}
|
||||
disabled={lines.length === 1}
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
||||
Conciliación
|
||||
</h2>
|
||||
<div className="filtered-totals">
|
||||
<div className="filtered-total">
|
||||
<span className="filtered-total-cur">{currency}</span>
|
||||
<span className="filtered-total-net">
|
||||
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
|
||||
</span>
|
||||
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
|
||||
</div>
|
||||
{outstandingTotal > 0 && (
|
||||
<div className="filtered-total">
|
||||
<span>
|
||||
Sin fondos{" "}
|
||||
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
|
||||
(no suma al cheque)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasCheckAmt && (
|
||||
<div className="filtered-total">
|
||||
<span className="filtered-total-net">
|
||||
{reconciled ? (
|
||||
<strong className="tx-amount pos">Cuadra con el cheque</strong>
|
||||
) : (
|
||||
<>
|
||||
Diferencia{" "}
|
||||
<strong className="tx-amount neg">
|
||||
{formatMoney(String(diff), currency)}
|
||||
</strong>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={saving || filled.length === 0}
|
||||
>
|
||||
{saving
|
||||
? "Guardando…"
|
||||
: `Capturar ${formatNumber(filled.length)} ${
|
||||
filled.length === 1 ? "recibo" : "recibos"
|
||||
}`}
|
||||
</button>
|
||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||
Cancelar
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ function BatchReview({ id }: { id: string }) {
|
||||
</p>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" href="/recibos">
|
||||
Volver
|
||||
Volver a captura
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -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<StatementBatchStatus, string> = {
|
||||
UPLOADED: "Recibido",
|
||||
PROCESSING: "Procesando…",
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Registrado",
|
||||
FAILED: "Falló",
|
||||
};
|
||||
|
||||
export default function RecibosPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Recibos />
|
||||
<Captura initialMode="auto" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<CaptureMode, string> = {
|
||||
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<CaptureMode>(
|
||||
modes.some((m) => m.key === initialMode) ? initialMode : (modes[0]?.key ?? "manual"),
|
||||
);
|
||||
|
||||
if (modes.length === 0) {
|
||||
return (
|
||||
<div className="state-box state-error">
|
||||
No tienes permiso para capturar movimientos.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Captura</h1>
|
||||
<p className="eyebrow">{MODE_HINT[mode]}</p>
|
||||
</div>
|
||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||
Volver a estado de cuenta
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{modes.length > 1 && (
|
||||
<div className="seg" role="tablist" style={{ marginBottom: 16 }}>
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === m.key}
|
||||
className={`seg-btn ${mode === m.key ? "active" : ""}`}
|
||||
onClick={() => setMode(m.key)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "manual" ? <ManualCheckCapture /> : <StatementIntake />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<BillingFacets | null>(null);
|
||||
|
||||
// Check-level fields — shared by every line.
|
||||
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
|
||||
const [currency, setCurrency] = useState<LedgerCurrency>("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<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
|
||||
const [nextKey, setNextKey] = useState(4);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [posted, setPosted] = useState<ByCheckResponse | null>(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<Line>) {
|
||||
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 (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h2 className="page-title">Lote capturado</h2>
|
||||
<p className="eyebrow">
|
||||
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
|
||||
{posted.count === 1 ? "movimiento" : "movimientos"}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={reset}>
|
||||
Capturar otro cheque
|
||||
</button>
|
||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||
Volver a estado de cuenta
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filtered-totals" style={{ marginBottom: 16 }}>
|
||||
{posted.totals.map((t) => (
|
||||
<div className="filtered-total" key={t.currency}>
|
||||
<span className="filtered-total-cur">{t.currency}</span>
|
||||
<span className="filtered-total-net">
|
||||
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
|
||||
</span>
|
||||
<span>{formatNumber(t.count)} movimientos</span>
|
||||
</div>
|
||||
))}
|
||||
{posted.outstandingCount > 0 && (
|
||||
<div className="filtered-total">
|
||||
<span>
|
||||
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
|
||||
total)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Cliente</th>
|
||||
<th>Referencia</th>
|
||||
<th>Periodo</th>
|
||||
<th>Estado</th>
|
||||
<th className="num">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posted.items.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<td>
|
||||
<Link
|
||||
href={`/estado-cuenta/${i.customerId}`}
|
||||
className="inline-link"
|
||||
>
|
||||
{i.customerName}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{i.reference || "—"}</td>
|
||||
<td>{i.period || "—"}</td>
|
||||
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
|
||||
<td className="num">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(i.amount, i.currency)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="muted" style={{ marginTop: 14 }}>
|
||||
Para imprimir la conciliación, usa el reporte{" "}
|
||||
<Link
|
||||
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
|
||||
posted.checkNumber,
|
||||
)}`}
|
||||
className="inline-link"
|
||||
>
|
||||
Reporte por cheque
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
<form onSubmit={submit}>
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
||||
Datos del cheque
|
||||
</h2>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Número de cheque *</span>
|
||||
<input
|
||||
className="input"
|
||||
value={checkNumber}
|
||||
onChange={(e) => setCheckNumber(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Fecha *</span>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
required
|
||||
value={transactionDate}
|
||||
onChange={(e) => setTransactionDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Línea de negocio *</span>
|
||||
<select
|
||||
className="select"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
|
||||
>
|
||||
{DOMAINS.map((d) => (
|
||||
<option key={d.key} value={d.key}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Moneda *</span>
|
||||
<select
|
||||
className="select"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
||||
>
|
||||
<option value="MXN">Pesos (MXN)</option>
|
||||
<option value="USD">Dólares (USD)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Concepto</span>
|
||||
<select
|
||||
className="select"
|
||||
value={typeId}
|
||||
onChange={(e) => setTypeId(e.target.value)}
|
||||
>
|
||||
<option value="">(sin concepto)</option>
|
||||
{facets?.types.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{txTypeLabel({ nameEn: t.name })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Importe del cheque</span>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={checkAmount}
|
||||
onChange={(e) => setCheckAmount(e.target.value)}
|
||||
placeholder="Para conciliar"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
<h2 className="section-title" style={{ margin: 0 }}>
|
||||
Recibos ({formatNumber(filled.length)})
|
||||
</h2>
|
||||
<button type="button" className="btn btn-outline" onClick={addLine}>
|
||||
Agregar línea
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ minWidth: 240 }}>Cliente *</th>
|
||||
<th style={{ minWidth: 120 }}>Referencia</th>
|
||||
<th style={{ minWidth: 100 }}>Periodo</th>
|
||||
<th style={{ minWidth: 110 }} className="num">
|
||||
Monto *
|
||||
</th>
|
||||
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
|
||||
<th style={{ width: 1 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((l) => (
|
||||
<tr key={l.key}>
|
||||
<td>
|
||||
<CustomerPicker
|
||||
value={l.customerId}
|
||||
valueName={l.customerId ? l.customerName : undefined}
|
||||
onPick={(id, name) =>
|
||||
update(l.key, { customerId: id, customerName: name })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input"
|
||||
value={l.reference}
|
||||
onChange={(e) =>
|
||||
update(l.key, { reference: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input"
|
||||
value={l.period}
|
||||
onChange={(e) => update(l.key, { period: e.target.value })}
|
||||
placeholder="2026-07"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="input num"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={l.amount}
|
||||
onChange={(e) => update(l.key, { amount: e.target.value })}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</td>
|
||||
<td style={{ textAlign: "center" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={l.outstanding}
|
||||
onChange={(e) =>
|
||||
update(l.key, { outstanding: e.target.checked })
|
||||
}
|
||||
aria-label="Sin fondos"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 10px", fontSize: 12 }}
|
||||
onClick={() => removeLine(l.key)}
|
||||
disabled={lines.length === 1}
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
||||
Conciliación
|
||||
</h2>
|
||||
<div className="filtered-totals">
|
||||
<div className="filtered-total">
|
||||
<span className="filtered-total-cur">{currency}</span>
|
||||
<span className="filtered-total-net">
|
||||
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
|
||||
</span>
|
||||
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
|
||||
</div>
|
||||
{outstandingTotal > 0 && (
|
||||
<div className="filtered-total">
|
||||
<span>
|
||||
Sin fondos{" "}
|
||||
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
|
||||
(no suma al cheque)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasCheckAmt && (
|
||||
<div className="filtered-total">
|
||||
<span className="filtered-total-net">
|
||||
{reconciled ? (
|
||||
<strong className="tx-amount pos">Cuadra con el cheque</strong>
|
||||
) : (
|
||||
<>
|
||||
Diferencia{" "}
|
||||
<strong className="tx-amount neg">
|
||||
{formatMoney(String(diff), currency)}
|
||||
</strong>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={saving || filled.length === 0}
|
||||
>
|
||||
{saving
|
||||
? "Guardando…"
|
||||
: `Capturar ${formatNumber(filled.length)} ${
|
||||
filled.length === 1 ? "recibo" : "recibos"
|
||||
}`}
|
||||
</button>
|
||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||
Cancelar
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<StatementBatchStatus, string> = {
|
||||
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<StatementBatch[]>([]);
|
||||
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null);
|
||||
const [storageAvailable, setStorageAvailable] = 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);
|
||||
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 (
|
||||
<div className="stack">
|
||||
{ocrAvailable === false && (
|
||||
<div className="state-box state-error">
|
||||
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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storageAvailable === false && (
|
||||
<div className="state-box state-error">
|
||||
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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canIngest && ready && <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>
|
||||
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user