fix(epay): 4 regressions from adversarial review of the hardening diff
Adversarial review (9 agents) of f7f7c59..28c870f found 4 confirmed bugs in the hardening itself; all fixed: 1. Parallel-download index race: two items with the SAME nrCadastral in one batch both scanned MinIO, both computed index 1, the second putObject silently overwrote the first paid extract. Pre-allocate per-cadastral indices sequentially before the parallel block; storeCfExtract takes an explicit index (epay-queue.ts, epay-storage.ts). 2. Metadata-fail orphan charge: on saveMetadata failure the row was popped from cleanup tracking even when deleteCartItem was NOT confirmed, leaving an undeletable metadata-less row in the global cart that submitOrder would check out and charge. Now: pop only on confirmed delete; if unconfirmed, mark cartDirty and ABORT before submit (epay-queue.ts). 3. Recover vs live queue race: the widened recover WHERE (orderId:null + cart/ordering/... states) could scoop a concurrently-processing batch's rows and re-stamp them with the wrong orderId. Block recover while getQueueStatus().processing (recover/route.ts). 4. 'review' status leaked as 'done' in the geoportal CF-order modal (minioPath short-circuit) — handed an unverified PDF as a finished extract. Check review/failed BEFORE the minioPath fallback (cf-order-modal.tsx). Plus 2 nits: download-zip excludes 'review' rows server-side; retry button surfaces recover errors/results instead of swallowing them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,7 @@ export async function GET(req: Request) {
|
|||||||
minioPath: true,
|
minioPath: true,
|
||||||
documentDate: true,
|
documentDate: true,
|
||||||
completedAt: true,
|
completedAt: true,
|
||||||
|
status: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +72,9 @@ export async function GET(req: Request) {
|
|||||||
for (let i = 0; i < ids.length; i++) {
|
for (let i = 0; i < ids.length; i++) {
|
||||||
const id = ids[i]!;
|
const id = ids[i]!;
|
||||||
const extract = extractMap.get(id);
|
const extract = extractMap.get(id);
|
||||||
if (!extract?.minioPath) continue;
|
// Skip rows without a file, and "review" rows (PDF present but the
|
||||||
|
// CF↔doc match is unverified — must not land in a "valid extracts" zip).
|
||||||
|
if (!extract?.minioPath || extract.status === "review") continue;
|
||||||
|
|
||||||
const dateForName = extract.documentDate ?? extract.completedAt ?? new Date();
|
const dateForName = extract.documentDate ?? extract.completedAt ?? new Date();
|
||||||
const d = new Date(dateForName);
|
const d = new Date(dateForName);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { recoverBatch } from "@/modules/parcel-sync/services/epay-queue";
|
import { recoverBatch, getQueueStatus } from "@/modules/parcel-sync/services/epay-queue";
|
||||||
import { requireCfAccess } from "@/core/auth/cf-access";
|
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -85,6 +85,22 @@ export async function GET(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-guard against the live batch queue: while a batch is processing,
|
||||||
|
// its rows sit at (orderId:null, status in cart/ordering/...) — exactly the
|
||||||
|
// orphan window the WHERE below matches. Recovering then would re-stamp a
|
||||||
|
// live batch's rows with THIS order's id (wrong PDF, status corruption).
|
||||||
|
// A genuinely-crashed batch leaves __epayQueueProcessing=false (reset on
|
||||||
|
// restart), so blocking here only defers against an actively-running queue.
|
||||||
|
if (getQueueStatus().processing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"O comandă ePay este în curs de procesare. Așteaptă finalizarea înainte de recuperare.",
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Candidate rows: anything already tagged with this order that isn't
|
// Candidate rows: anything already tagged with this order that isn't
|
||||||
// terminal, PLUS recent orphaned rows (orderId:null) in a recoverable
|
// terminal, PLUS recent orphaned rows (orderId:null) in a recoverable
|
||||||
// state — the operator asserts they belong to this order.
|
// state — the operator asserts they belong to this order.
|
||||||
|
|||||||
@@ -254,6 +254,23 @@ export function CfOrderModal({
|
|||||||
const row = (data.orders ?? []).find((o) => o.id === id);
|
const row = (data.orders ?? []).find((o) => o.id === id);
|
||||||
if (row) {
|
if (row) {
|
||||||
const s = (row.status || "").toLowerCase();
|
const s = (row.status || "").toLowerCase();
|
||||||
|
// "review" rows DO have minioPath + documentName but the PDF
|
||||||
|
// may belong to another parcel (ambiguous CF↔doc match) — must
|
||||||
|
// be checked BEFORE the minioPath/documentName completion
|
||||||
|
// fallback, or it would short-circuit to "done" and hand the
|
||||||
|
// operator an unverified extract.
|
||||||
|
if (s === "review") {
|
||||||
|
setError(
|
||||||
|
"Comanda necesită verificare manuală (potrivire ambiguă document↔parcelă). Verifică în ParcelSync → ePay înainte de a folosi extrasul.",
|
||||||
|
);
|
||||||
|
setPhase("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s === "failed" || s === "error") {
|
||||||
|
setError("Comanda a eșuat la ANCPI.");
|
||||||
|
setPhase("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
s === "completed" ||
|
s === "completed" ||
|
||||||
s === "done" ||
|
s === "done" ||
|
||||||
@@ -264,11 +281,6 @@ export function CfOrderModal({
|
|||||||
setPhase("done");
|
setPhase("done");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (s === "failed" || s === "error") {
|
|
||||||
setError("Comanda a eșuat la ANCPI.");
|
|
||||||
setPhase("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -328,17 +328,35 @@ export function EpayTab() {
|
|||||||
* order, no new charge. For rows that failed at the download/poll
|
* order, no new charge. For rows that failed at the download/poll
|
||||||
* stage (the order exists at ANCPI but we never stored the PDF). -- */
|
* stage (the order exists at ANCPI but we never stored the PDF). -- */
|
||||||
const [retryingId, setRetryingId] = useState<string | null>(null);
|
const [retryingId, setRetryingId] = useState<string | null>(null);
|
||||||
|
const [retryNotice, setRetryNotice] = useState<string | null>(null);
|
||||||
const handleRetryDownload = async (order: CfExtractRecord) => {
|
const handleRetryDownload = async (order: CfExtractRecord) => {
|
||||||
setRetryingId(order.id);
|
setRetryingId(order.id);
|
||||||
|
setRetryNotice(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/ancpi/recover?extractId=${encodeURIComponent(order.id)}`,
|
`/api/ancpi/recover?extractId=${encodeURIComponent(order.id)}`,
|
||||||
);
|
);
|
||||||
// 409 → the row has no orderId yet (never reached ANCPI); nothing to
|
const data = (await res.json().catch(() => ({}))) as {
|
||||||
// recover by row. Other errors surface on the next refresh.
|
error?: string;
|
||||||
await res.json().catch(() => ({}));
|
completed?: number;
|
||||||
|
attempted?: number;
|
||||||
|
};
|
||||||
|
if (!res.ok) {
|
||||||
|
// 409 (queue busy / no orderId yet), 404, 500 — tell the user.
|
||||||
|
setRetryNotice(data.error ?? `Reîncercare eșuată (${res.status}).`);
|
||||||
|
} else if ((data.completed ?? 0) > 0) {
|
||||||
|
setRetryNotice(
|
||||||
|
`Recuperat: ${data.completed}/${data.attempted ?? data.completed} extrase.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setRetryNotice(
|
||||||
|
"Nimic de recuperat — comanda nu există la ANCPI sau e deja finalizată.",
|
||||||
|
);
|
||||||
|
}
|
||||||
void fetchOrders(true);
|
void fetchOrders(true);
|
||||||
void fetchEpayStatus();
|
void fetchEpayStatus();
|
||||||
|
} catch {
|
||||||
|
setRetryNotice("Eroare rețea la reîncercare.");
|
||||||
} finally {
|
} finally {
|
||||||
setRetryingId(null);
|
setRetryingId(null);
|
||||||
}
|
}
|
||||||
@@ -911,6 +929,20 @@ export function EpayTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* -- Retry notice ------------------------------------------- */}
|
||||||
|
{retryNotice && (
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs">
|
||||||
|
<span>{retryNotice}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setRetryNotice(null)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* -- Active orders indicator -------------------------------- */}
|
{/* -- Active orders indicator -------------------------------- */}
|
||||||
{hasActive && (
|
{hasActive && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { EpayClient } from "./epay-client";
|
import { EpayClient } from "./epay-client";
|
||||||
import { getEpayCredentials, updateEpayCredits } from "./epay-session-store";
|
import { getEpayCredentials, updateEpayCredits } from "./epay-session-store";
|
||||||
import { storeCfExtract } from "./epay-storage";
|
import { storeCfExtract, getNextFileIndex } from "./epay-storage";
|
||||||
import type { CfExtractCreateInput } from "./epay-types";
|
import type { CfExtractCreateInput } from "./epay-types";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -310,8 +310,10 @@ async function processBatch(
|
|||||||
// bail the moment ANCPI reports more rows than we put in.
|
// bail the moment ANCPI reports more rows than we put in.
|
||||||
// cartCount tracks the rows actually in the cart (incremented on add,
|
// cartCount tracks the rows actually in the cart (incremented on add,
|
||||||
// decremented only on a CONFIRMED delete) so the invariant stays correct
|
// decremented only on a CONFIRMED delete) so the invariant stays correct
|
||||||
// even if a cleanup delete fails.
|
// even if a cleanup delete fails. cartDirty trips when a metadata-less
|
||||||
|
// row could not be confirmed-deleted → we must not submit.
|
||||||
let cartCount = 0;
|
let cartCount = 0;
|
||||||
|
let cartDirty = false;
|
||||||
for (let idx = 0; idx < items.length; idx++) {
|
for (let idx = 0; idx < items.length; idx++) {
|
||||||
const item = items[idx]!;
|
const item = items[idx]!;
|
||||||
const { extractId, input } = item;
|
const { extractId, input } = item;
|
||||||
@@ -384,16 +386,41 @@ async function processBatch(
|
|||||||
await updateStatus(extractId, "failed", {
|
await updateStatus(extractId, "failed", {
|
||||||
errorMessage: "Salvarea metadatelor în ePay a eșuat.",
|
errorMessage: "Salvarea metadatelor în ePay a eșuat.",
|
||||||
});
|
});
|
||||||
// Remove this metadata-less row from the cart so it can't be
|
item.basketRowId = undefined; // exclude from validItems regardless
|
||||||
// checked out and charged. Only decrement cartCount if ANCPI
|
// Remove this metadata-less row from the cart so it can't be checked
|
||||||
// confirmed the delete — otherwise the row is still there and the
|
// out and charged. Only drop it from tracking + decrement cartCount
|
||||||
// invariant must keep counting it.
|
// if ANCPI CONFIRMED the delete; otherwise the row is still in the
|
||||||
|
// cart, must stay in cleanup tracking, and the cart is now "dirty".
|
||||||
const deleted = await client.deleteCartItem(basketRowId, idx);
|
const deleted = await client.deleteCartItem(basketRowId, idx);
|
||||||
if (deleted) cartCount--;
|
if (deleted) {
|
||||||
|
cartCount--;
|
||||||
ourBasketIdsForCleanup.pop();
|
ourBasketIdsForCleanup.pop();
|
||||||
item.basketRowId = undefined;
|
} else {
|
||||||
|
// Undeletable metadata-less row → submitting now would check it out
|
||||||
|
// and charge for it. Refuse to submit this batch.
|
||||||
|
cartDirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A metadata-less row we couldn't remove is still in the global cart;
|
||||||
|
// submitOrder would check out the WHOLE cart and charge for it. Abort
|
||||||
|
// (best-effort wipe) instead of submitting a cart we can't guarantee
|
||||||
|
// clean. The catch below would NOT fire on the success path, so this
|
||||||
|
// explicit guard is what stops the unintended charge.
|
||||||
|
if (cartDirty) {
|
||||||
|
console.error(
|
||||||
|
"[epay-queue] Cart has an undeletable metadata-less row — aborting before submit.",
|
||||||
|
);
|
||||||
|
await client.deleteCartItems(ourBasketIdsForCleanup);
|
||||||
|
for (const id of extractIds) {
|
||||||
|
await updateStatus(id, "failed", {
|
||||||
|
errorMessage:
|
||||||
|
"Coș ePay nu a putut fi curățat (ștergere neconfirmată) — comanda a fost oprită pentru a evita o plată fără metadate. Reîncearcă.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Filter to only items that had successful metadata saves
|
// Filter to only items that had successful metadata saves
|
||||||
const validItems = items.filter((i) => i.basketRowId !== undefined);
|
const validItems = items.filter((i) => i.basketRowId !== undefined);
|
||||||
@@ -506,8 +533,14 @@ async function finalizeOrder(
|
|||||||
item: QueueItem;
|
item: QueueItem;
|
||||||
doc: (typeof downloadableDocs)[number];
|
doc: (typeof downloadableDocs)[number];
|
||||||
matchedByIndex: boolean;
|
matchedByIndex: boolean;
|
||||||
|
index: number;
|
||||||
};
|
};
|
||||||
const plans: Plan[] = [];
|
const plans: Plan[] = [];
|
||||||
|
// Per-cadastral file index, pre-allocated SEQUENTIALLY so two parallel
|
||||||
|
// tasks for the same cadastral never collide on the MinIO scan (which is
|
||||||
|
// a read-modify-write). Seed each distinct cadastral from MinIO once,
|
||||||
|
// then hand out 1,2,… within this batch.
|
||||||
|
const nextIndexByCad = new Map<string, number>();
|
||||||
for (let i = 0; i < validItems.length; i++) {
|
for (let i = 0; i < validItems.length; i++) {
|
||||||
const item = validItems[i]!;
|
const item = validItems[i]!;
|
||||||
const nrCF = item.input.nrCF ?? item.input.nrCadastral;
|
const nrCF = item.input.nrCF ?? item.input.nrCadastral;
|
||||||
@@ -532,12 +565,19 @@ async function finalizeOrder(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
plans.push({ item, doc, matchedByIndex });
|
|
||||||
|
const cad = item.input.nrCadastral;
|
||||||
|
let next = nextIndexByCad.get(cad);
|
||||||
|
if (next === undefined) next = await getNextFileIndex(cad);
|
||||||
|
nextIndexByCad.set(cad, next + 1);
|
||||||
|
|
||||||
|
plans.push({ item, doc, matchedByIndex, index: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: download + store in parallel (bounded). Each task is fully
|
// Step 6: download + store in parallel (bounded). Each task is fully
|
||||||
// self-contained so a failure on one row doesn't abort the others.
|
// self-contained so a failure on one row doesn't abort the others. The
|
||||||
await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async ({ item, doc, matchedByIndex }) => {
|
// file index is pre-allocated above, so parallel stores never overwrite.
|
||||||
|
await runWithConcurrency(plans, DOWNLOAD_CONCURRENCY, async ({ item, doc, matchedByIndex, index: fileIndex }) => {
|
||||||
try {
|
try {
|
||||||
await updateStatus(item.extractId, "downloading", {
|
await updateStatus(item.extractId, "downloading", {
|
||||||
idDocument: doc.idDocument,
|
idDocument: doc.idDocument,
|
||||||
@@ -559,6 +599,7 @@ async function finalizeOrder(
|
|||||||
stare: finalStatus.status,
|
stare: finalStatus.status,
|
||||||
produs: "EXI_ONLINE",
|
produs: "EXI_ONLINE",
|
||||||
},
|
},
|
||||||
|
fileIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!doc.dataDocument) {
|
if (!doc.dataDocument) {
|
||||||
|
|||||||
@@ -83,15 +83,25 @@ export function buildFileName(
|
|||||||
/**
|
/**
|
||||||
* Store a CF extract PDF in MinIO.
|
* Store a CF extract PDF in MinIO.
|
||||||
* Returns the MinIO path and file index.
|
* Returns the MinIO path and file index.
|
||||||
|
*
|
||||||
|
* Pass `explicitIndex` to skip the MinIO scan and use a caller-allocated
|
||||||
|
* index. Required when storing concurrently (parallel downloads): the scan
|
||||||
|
* is an unsynchronised read-modify-write, so two tasks for the same
|
||||||
|
* cadastral would both compute index 1 and the second putObject would
|
||||||
|
* silently overwrite the first. The caller pre-allocates distinct indices.
|
||||||
*/
|
*/
|
||||||
export async function storeCfExtract(
|
export async function storeCfExtract(
|
||||||
pdfBuffer: Buffer,
|
pdfBuffer: Buffer,
|
||||||
nrCadastral: string,
|
nrCadastral: string,
|
||||||
metadata: Record<string, string>,
|
metadata: Record<string, string>,
|
||||||
|
explicitIndex?: number,
|
||||||
): Promise<{ path: string; fileName: string; index: number }> {
|
): Promise<{ path: string; fileName: string; index: number }> {
|
||||||
await ensureAncpiBucket();
|
await ensureAncpiBucket();
|
||||||
|
|
||||||
const index = await getNextFileIndex(nrCadastral);
|
const index =
|
||||||
|
explicitIndex !== undefined
|
||||||
|
? explicitIndex
|
||||||
|
: await getNextFileIndex(nrCadastral);
|
||||||
const fileName = buildFileName(index, nrCadastral, new Date());
|
const fileName = buildFileName(index, nrCadastral, new Date());
|
||||||
// Store in subfolder per cadastral number
|
// Store in subfolder per cadastral number
|
||||||
const path = `parcele/${nrCadastral}/${fileName}`;
|
const path = `parcele/${nrCadastral}/${fileName}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user