feat(ocr): discard abandoned capture batches

A bad scan, the wrong PDFs or a duplicate upload used to leave a batch
sitting in READY_FOR_REVIEW forever, because the only exits were confirm
(posts to the books) or rejecting every page one at a time. Add a
DISCARDED terminal status to both OCR domains and a single endpoint per
domain that rejects every page still pending in one shot.

Discarding is refused once anything has landed: statements once a page is
POSTED, policies once a page is APPLIED. Those batches did real work and
have to be settled page by page.

- POST /statements/batches/:id/discard
- POST /policy-ocr/batches/:id/discard
- shared DiscardBatchCard on both review screens, gated the same way
This commit is contained in:
2026-08-02 02:00:02 -07:00
parent 905fa31e47
commit 3125b52057
13 changed files with 327 additions and 8 deletions
@@ -143,6 +143,18 @@ async pageImage(
return doc;
}
/** Abandon a batch pending review — rejects every unapplied page. */
@Post("batches/:id/discard")
@RequireAbility("policy:ocr-review")
async discard(@Param("id") id: string, @Req() req: Request) {
const result = await this.policyOcr.discardBatch(id, this.actingId(req));
void this.audit.log(this.actingId(req), "policyOcr.batch.discard", {
batchId: id,
rejected: result.rejected,
});
return result;
}
@Post("batches/:id/confirm")
@RequireAbility("policy:ocr-review")
async confirm(
+50 -3
View File
@@ -364,10 +364,55 @@ export class PolicyOcrService {
if (doc.status === "POSTED") {
throw new BadRequestException("Este documento ya fue aplicado.");
}
return this.prisma.policyOcrDocument.update({
const updated = await this.prisma.policyOcrDocument.update({
where: { id },
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
});
// Rejecting the last open page settles the batch just as confirming it
// would — without this, a fully-rejected batch sat in READY_FOR_REVIEW
// forever because only confirmBatch() ever closed one.
await this.closeIfDone(doc.batchId);
return updated;
}
/**
* Throw away a whole batch that is pending review: every page that has not
* been applied is marked REJECTED and the batch itself becomes DISCARDED.
*
* Refuses once any page is POSTED — a partly-applied batch has already
* written Policy (and possibly Transaction) rows, and hiding the paperwork
* behind a "discarded" label would leave those rows unexplained. Reject the
* remaining pages individually instead.
*/
async discardBatch(batchId: string, reviewedById: string) {
const batch = await this.prisma.policyOcrBatch.findUnique({
where: { id: batchId },
});
if (!batch) throw new NotFoundException("Lote no encontrado.");
if (batch.status === "DISCARDED") {
throw new BadRequestException("Este lote ya fue descartado.");
}
const posted = await this.prisma.policyOcrDocument.count({
where: { batchId, status: "POSTED" },
});
if (posted > 0) {
throw new BadRequestException(
`No se puede descartar: ${posted} página(s) ya se aplicaron a una póliza.`,
);
}
const { count } = await this.prisma.policyOcrDocument.updateMany({
where: { batchId, status: { notIn: ["POSTED", "REJECTED"] } },
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
});
await this.prisma.policyOcrBatch.update({
where: { id: batchId },
data: { status: "DISCARDED", completedAt: new Date() },
});
return { batchId, rejected: count };
}
// --- confirm --------------------------------------------------------------
@@ -533,8 +578,10 @@ export class PolicyOcrService {
},
});
if (open === 0) {
await this.prisma.policyOcrBatch.update({
where: { id: batchId },
await this.prisma.policyOcrBatch.updateMany({
// `updateMany` + a status filter so a discarded batch is never quietly
// relabelled COMPLETED by a late reject on one of its pages.
where: { id: batchId, status: { not: "DISCARDED" } },
data: { status: "COMPLETED", completedAt: new Date() },
});
}
@@ -142,6 +142,18 @@ export class StatementsController {
return doc;
}
/** Abandon a batch pending review — rejects every unposted page. */
@Post("batches/:id/discard")
@RequireAbility("statement:review")
async discard(@Param("id") id: string, @Req() req: Request) {
const result = await this.statements.discardBatch(id, this.actingId(req));
void this.audit.log(this.actingId(req), "statement.batch.discard", {
batchId: id,
rejected: result.rejected,
});
return result;
}
/** Post every matched document in the batch, against one check. */
@Post("batches/:id/confirm")
@RequireAbility("statement:review")
+49 -3
View File
@@ -342,10 +342,54 @@ export class StatementsService {
if (doc.status === "POSTED") {
throw new BadRequestException("Este documento ya fue registrado.");
}
return this.prisma.statementDocument.update({
const updated = await this.prisma.statementDocument.update({
where: { id },
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
});
// Rejecting the last open page settles the batch just as posting it would
// — without this, a fully-rejected batch sat in READY_FOR_REVIEW forever
// because only confirmBatch() ever closed one.
await this.closeIfDone(doc.batchId);
return updated;
}
/**
* Throw away a whole batch that is pending review: every page that has not
* been posted is marked REJECTED and the batch itself becomes DISCARDED.
*
* Refuses once any page is POSTED — those pages already wrote ledger rows
* against a check, and a "discarded" label on the batch would leave those
* charges unexplained. Reject the remaining pages individually instead.
*/
async discardBatch(batchId: string, reviewedById: string) {
const batch = await this.prisma.statementBatch.findUnique({
where: { id: batchId },
});
if (!batch) throw new NotFoundException("Lote no encontrado.");
if (batch.status === "DISCARDED") {
throw new BadRequestException("Este lote ya fue descartado.");
}
const posted = await this.prisma.statementDocument.count({
where: { batchId, status: "POSTED" },
});
if (posted > 0) {
throw new BadRequestException(
`No se puede descartar: ${posted} página(s) ya se registraron en el estado de cuenta.`,
);
}
const { count } = await this.prisma.statementDocument.updateMany({
where: { batchId, status: { notIn: ["POSTED", "REJECTED"] } },
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
});
await this.prisma.statementBatch.update({
where: { id: batchId },
data: { status: "DISCARDED", completedAt: new Date() },
});
return { batchId, rejected: count };
}
// --- posting --------------------------------------------------------------
@@ -461,8 +505,10 @@ export class StatementsService {
where: { batchId, status: { in: OPEN } },
});
if (open === 0) {
await this.prisma.statementBatch.update({
where: { id: batchId },
await this.prisma.statementBatch.updateMany({
// `updateMany` + a status filter so a discarded batch is never quietly
// relabelled COMPLETED by a late reject on one of its pages.
where: { id: batchId, status: { not: "DISCARDED" } },
data: { status: "COMPLETED", completedAt: new Date() },
});
}