feat(recibos): split a receipt, sin fondos per receipt, keep a batch open
Three asks from the 2026-10-08 meeting, all on the OCR receipt queue. - Split: one receipt paid by several customers (411 + 523 sharing water 3014057) posts one charge per part. statement_document_splits holds free amounts that must sum to the receipt in cents, checked on save and again at confirm; "partes iguales" fills them in. Each part's row carries captureRef <docId>#<n>, so the double-post guard still holds. - Sin fondos per receipt: StatementDocument.outstanding, toggled in review. At confirm a line is outstanding if its receipt or the whole check is. - Open batch: POST /statements/batches/:id/files appends scans, numbered after the existing pages; a COMPLETED batch reopens. The confirm card prefills the check the batch was last posted against. Verified on dev against a real batch: 1,892.00 split 946/946 across two customers, a sibling receipt posted outstanding, second confirm refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f655fcbb82
commit
fd5df240bc
9 files changed
+756
-49
No files matched your search
@@ -6,18 +6,21 @@ import { AppShell } from "@/components/AppShell";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
||||
import {
|
||||
addStatementFiles,
|
||||
confirmStatementBatch,
|
||||
discardStatementBatch,
|
||||
getStatementBatch,
|
||||
listStatementDocuments,
|
||||
rejectStatementDocument,
|
||||
reviewStatementDocument,
|
||||
setStatementSplits,
|
||||
statementPageUrl,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
|
||||
import type {
|
||||
ConfirmBatchInput,
|
||||
SplitPartInput,
|
||||
StatementBatchDetail,
|
||||
StatementDocument,
|
||||
StatementDocumentStatus,
|
||||
@@ -64,6 +67,7 @@ export default function RecibosBatchPage({ params }: { params: { id: string } })
|
||||
|
||||
function BatchReview({ id }: { id: string }) {
|
||||
const canReview = useCan("statement:review");
|
||||
const canIngest = useCan("statement:ingest");
|
||||
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
|
||||
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -108,7 +112,7 @@ function BatchReview({ id }: { id: string }) {
|
||||
);
|
||||
|
||||
const readyCount = docs.filter(
|
||||
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
||||
(d) => d.status === "MATCHED" && (d.matchedCustomer || d.splits.length > 0),
|
||||
).length;
|
||||
|
||||
async function discard() {
|
||||
@@ -162,9 +166,14 @@ function BatchReview({ id }: { id: string }) {
|
||||
|
||||
<SummaryCard batch={batch} readyCount={readyCount} />
|
||||
|
||||
{canIngest && !processing && batch.status !== "DISCARDED" && (
|
||||
<AddFilesCard batchId={id} onDone={load} setError={setError} />
|
||||
)}
|
||||
|
||||
{canReview && readyCount > 0 && (
|
||||
<ConfirmCard
|
||||
batchId={id}
|
||||
lastCheckNumber={batch.lastCheckNumber}
|
||||
readyCount={readyCount}
|
||||
onDone={load}
|
||||
setError={setError}
|
||||
@@ -291,16 +300,19 @@ function SummaryCard({
|
||||
*/
|
||||
function ConfirmCard({
|
||||
batchId,
|
||||
lastCheckNumber,
|
||||
readyCount,
|
||||
onDone,
|
||||
setError,
|
||||
}: {
|
||||
batchId: string;
|
||||
lastCheckNumber: string | null;
|
||||
readyCount: number;
|
||||
onDone: () => void;
|
||||
setError: (m: string | null) => void;
|
||||
}) {
|
||||
const [checkNumber, setCheckNumber] = useState("");
|
||||
// A batch kept open for one check is posted in rounds against that check.
|
||||
const [checkNumber, setCheckNumber] = useState(lastCheckNumber ?? "");
|
||||
const [transactionDate, setTransactionDate] = useState(
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
@@ -322,9 +334,9 @@ function ConfirmCard({
|
||||
};
|
||||
const r = await confirmStatementBatch(batchId, input);
|
||||
setResult(
|
||||
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}.`,
|
||||
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}` +
|
||||
(r.outstanding ? `; ${r.outstanding} sin fondos.` : "."),
|
||||
);
|
||||
setCheckNumber("");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
|
||||
@@ -363,7 +375,7 @@ function ConfirmCard({
|
||||
checked={outstanding}
|
||||
onChange={(e) => setOutstanding(e.target.checked)}
|
||||
/>{" "}
|
||||
Sin fondos (queda pendiente)
|
||||
Todo el cheque sin fondos
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
@@ -389,12 +401,65 @@ function ConfirmCard({
|
||||
)}
|
||||
<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.
|
||||
manual. Un lote registrado dos veces no duplica cobros. Para dejar
|
||||
pendiente un solo recibo, márcalo «Sin fondos» en el recibo.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** More scans for this batch — it stays open while receipts keep arriving. */
|
||||
function AddFilesCard({
|
||||
batchId,
|
||||
onDone,
|
||||
setError,
|
||||
}: {
|
||||
batchId: string;
|
||||
onDone: () => void;
|
||||
setError: (m: string | null) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function upload() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await addStatementFiles(batchId, files);
|
||||
setFiles([]);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudieron agregar los archivos.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Agregar recibos a este lote</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
disabled={!files.length || busy}
|
||||
onClick={upload}
|
||||
>
|
||||
{busy ? "Subiendo…" : `Agregar ${files.length || ""} archivo(s)`}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentRow({
|
||||
doc,
|
||||
canReview,
|
||||
@@ -411,6 +476,7 @@ function DocumentRow({
|
||||
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
|
||||
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
|
||||
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
|
||||
const [outstanding, setOutstanding] = useState(doc.outstanding);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
@@ -424,6 +490,7 @@ function DocumentRow({
|
||||
accountRef: accountRef.trim() || undefined,
|
||||
amount: amount ? Number(amount) : undefined,
|
||||
matchedCustomerId: customerId || undefined,
|
||||
outstanding,
|
||||
status,
|
||||
});
|
||||
onChange();
|
||||
@@ -456,9 +523,21 @@ function DocumentRow({
|
||||
<div>
|
||||
<strong>Página {doc.pageNumber}</strong>{" "}
|
||||
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
|
||||
{doc.outstanding && <span className="tag">Sin fondos</span>}{" "}
|
||||
{doc.provider && <span className="page-sub">{doc.provider}</span>}
|
||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||
{doc.matchedCustomer ? (
|
||||
{doc.splits.length > 0 ? (
|
||||
<>
|
||||
Dividido:{" "}
|
||||
{doc.splits.map((s, i) => (
|
||||
<span key={s.id}>
|
||||
{i > 0 && " + "}
|
||||
<Link href={`/clientes/${s.customer.id}`}>{s.customer.name}</Link>{" "}
|
||||
{formatMoney(s.amount, "MXN")}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : doc.matchedCustomer ? (
|
||||
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
|
||||
{doc.matchedCustomer.name}
|
||||
</Link>
|
||||
@@ -533,10 +612,18 @@ function DocumentRow({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={outstanding}
|
||||
onChange={(e) => setOutstanding(e.target.checked)}
|
||||
/>{" "}
|
||||
Sin fondos
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !customerId}
|
||||
disabled={busy || (!customerId && doc.splits.length === 0)}
|
||||
onClick={() => save("MATCHED")}
|
||||
>
|
||||
Guardar
|
||||
@@ -552,9 +639,164 @@ function DocumentRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canReview && !settled && (
|
||||
<SplitEditor doc={doc} onChange={onChange} />
|
||||
)}
|
||||
|
||||
{err && <div className="state-box state-error">{err}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Part = { customerId: string; customerName: string; amount: string };
|
||||
|
||||
/** Equal parts in cents; the odd cents go to the first parts. */
|
||||
function equalParts(total: number, n: number): string[] {
|
||||
const cents = Math.round(total * 100);
|
||||
const base = Math.floor(cents / n);
|
||||
return Array.from({ length: n }, (_, i) =>
|
||||
((base + (i < cents - base * n ? 1 : 0)) / 100).toFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One receipt paid by several customers — a water bill two neighbours share.
|
||||
* Amounts are free (the office splits unevenly as often as not) but must add
|
||||
* up to the receipt; "partes iguales" fills them in.
|
||||
*/
|
||||
function SplitEditor({ doc, onChange }: { doc: StatementDocument; onChange: () => void }) {
|
||||
const total = Number(doc.extractedAmount ?? 0);
|
||||
const [parts, setParts] = useState<Part[]>(() =>
|
||||
doc.splits.map((s) => ({
|
||||
customerId: s.customer.id,
|
||||
customerName: s.customer.name,
|
||||
amount: Number(s.amount).toFixed(2),
|
||||
})),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const sum = parts.reduce((s, p) => s + Math.round(Number(p.amount || 0) * 100), 0) / 100;
|
||||
const balanced = Math.round(sum * 100) === Math.round(total * 100);
|
||||
const complete = parts.length >= 2 && parts.every((p) => p.customerId && Number(p.amount) > 0);
|
||||
|
||||
function setPart(i: number, patch: Partial<Part>) {
|
||||
setParts((prev) => prev.map((p, j) => (j === i ? { ...p, ...patch } : p)));
|
||||
}
|
||||
|
||||
function start() {
|
||||
const first = {
|
||||
customerId: doc.matchedCustomer?.id ?? "",
|
||||
customerName: doc.matchedCustomer?.name ?? "",
|
||||
};
|
||||
const [a, b] = equalParts(total, 2);
|
||||
setParts([
|
||||
{ ...first, amount: a },
|
||||
{ customerId: "", customerName: "", amount: b },
|
||||
]);
|
||||
}
|
||||
|
||||
function evenly() {
|
||||
const amounts = equalParts(total, parts.length);
|
||||
setParts((prev) => prev.map((p, i) => ({ ...p, amount: amounts[i] })));
|
||||
}
|
||||
|
||||
async function save(next: SplitPartInput[]) {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await setStatementSplits(doc.id, next);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? "No se pudo guardar la división.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={!doc.extractedAmount}
|
||||
title={doc.extractedAmount ? undefined : "Captura el importe primero"}
|
||||
onClick={start}
|
||||
>
|
||||
Dividir entre varios clientes
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: 8 }}>
|
||||
<strong>División del recibo ({formatMoney(total, "MXN")})</strong>
|
||||
{parts.map((p, i) => (
|
||||
<div key={i} className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<div style={{ minWidth: 260 }}>
|
||||
<CustomerPicker
|
||||
value={p.customerId}
|
||||
valueName={p.customerName}
|
||||
onPick={(cid, name) => setPart(i, { customerId: cid, customerName: name })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="decimal"
|
||||
style={{ width: 120 }}
|
||||
value={p.amount}
|
||||
onChange={(e) => setPart(i, { amount: e.target.value })}
|
||||
/>
|
||||
{parts.length > 2 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setParts((prev) => prev.filter((_, j) => j !== i))}
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setParts((prev) => [...prev, { customerId: "", customerName: "", amount: "" }])}
|
||||
>
|
||||
Agregar cliente
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={evenly}>
|
||||
Partes iguales
|
||||
</button>
|
||||
<span className={balanced ? "page-sub" : "state-error"}>
|
||||
Suma {formatMoney(sum, "MXN")}
|
||||
{!balanced && ` — diferencia ${formatMoney(total - sum, "MXN")}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !balanced || !complete}
|
||||
onClick={() =>
|
||||
save(parts.map((p) => ({ customerId: p.customerId, amount: Number(p.amount) })))
|
||||
}
|
||||
>
|
||||
Guardar división
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
disabled={busy}
|
||||
onClick={() => (doc.splits.length ? save([]) : setParts([]))}
|
||||
>
|
||||
Quitar división
|
||||
</button>
|
||||
</div>
|
||||
{err && <div className="state-box state-error">{err}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user