feat(collection): return mistaken pickups to pool

This commit is contained in:
Dave 2026-10-21 03:37:01 +02:00
parent 42a20d7e72
commit 511f5dac6e
14 changed files with 788 additions and 36 deletions

View file

@ -0,0 +1,51 @@
# Return mistaken collections to the pool
Collection Confirm has a **Return to pool** button on each line and a
**Return PO to collection pool** button at the bottom of each PO card.
The PO button affects only the collected lines shown on that card, not
other lines on the customer order. Both open a confirmation listing the
affected items before making any changes.
The reset clears driver assignment, collection dates, collected and
assigned quantities, collection problem flags and the previous AI check.
Order quantities, costs, SKUs, photos and notes are retained. Customer
lines return to `item_status = collection_pool`; stock lines return to
`status = collection_pool` with `quantity_received = 0`. An unbooked stock
header marked `received` reopens to `sent` so it leaves the accountant's
booking queue until collection is confirmed again.
Items already invoiced, dispatched, delivered or booked into stock are
refused. Refused lines are reported separately if some lines can return.
The browser reloads the queue after the operation. Normal confirmation
stays available; Add to POS controls are hidden but their underlying
handlers and endpoints remain.
## Deployment
Deploy **both** the app and `pocketbase/pb_hooks/collection_return.pb.js`.
The hook is listed in `pocketbase/pb_hooks/DEPLOY`; publish the committed
hooks folder with `npm run pb:hooks:publish` using the workflow in
`scripts/pb-hooks/README.md`. No collection fields or select values need
to be added.
The authenticated capability endpoint
`GET /api/wfdash/collection-return-guard` must return `{ "version": 1 }`.
Until the hook is deployed the app refuses resets with HTTP 503. It must
never fall back to an unguarded update.
Each return uses a PB batch transaction. The hook rechecks row revisions,
stock parent revisions, invoice metadata and stock ledger entries inside
that transaction before allowing the writes. It also guards the opposite
race: a stale customer confirmation cannot advance a returned/assigned
line, and a stock booking cannot insert a receipt after the PO reopens.
Existing PB audit hooks record customer field changes; no extra app audit
notes are added.
## Validation
Run the tests under `src/components/collection-confirm`,
`src/app/(app)/collection-confirm`, `src/app/api/v1/collection-confirm`,
and `src/lib/server/collection-return*.test.ts`. The hook tests evaluate
the real JS file in a mocked PB environment; they do not change live data.
After deployment, verify a mistaken collection returns to the pool with
zero collected quantity and can be assigned to a driver again.

View file

@ -8,3 +8,4 @@
quotes.pb.js
audit_log_utils.js
audit_log.pb.js
collection_return.pb.js

View file

@ -0,0 +1,94 @@
/**
* Revision checks for the Collection Confirm return-to-pool batch.
* PB runs each child record request (and these hooks) inside the batch's
* database transaction. A concurrent confirm/invoice/booking therefore
* either precedes these checks or follows the complete reset; it cannot
* slip between validation and the writes. Ordinary updates are untouched.
* Deploy with the app; the service refuses resets without this capability.
*/
routerAdd("GET", "/api/wfdash/collection-return-guard", (e) => {
return e.json(200, { version: 1 });
}, $apis.requireSuperuserAuth());
onRecordUpdateRequest((e) => {
const info = e.requestInfo();
const guard = info.body && info.body._collection_return_guard;
if (!guard) {
// A confirmer may have read the row before a return committed. Check
// its current state and save together, so it cannot resurrect a pickup.
if (e.record.collection().name === "order_items" && info.body && info.body.item_status === "ready_invoice") {
const originalApp = e.app;
try {
return originalApp.runInTransaction((txApp) => {
e.app = txApp;
const latest = JSON.parse(JSON.stringify(txApp.findRecordById("order_items", e.record.id || e.record.original().id)));
const original = JSON.parse(JSON.stringify(e.record.original()));
if (latest.updated !== original.updated || ["collection_pool", "assigned"].indexOf(latest.item_status) !== -1) {
throw new BadRequestError("Collection changed. Refresh before confirming for invoicing.");
}
return e.next();
});
} finally { e.app = originalApp; }
}
return e.next();
}
if (!e.auth || !e.auth.isSuperuser()) throw new ForbiddenError("Collection resets require the server.");
if (info.context !== "batch") throw new BadRequestError("Collection resets must run in a batch transaction.");
const current = JSON.parse(JSON.stringify(e.record.original()));
if (!guard.updated || current.updated !== guard.updated) {
throw new BadRequestError("This item changed. Refresh Collection Confirm and try again.");
}
const collection = e.record.collection().name;
let refType = "order_item";
let refId = current.id;
if (collection === "order_items") {
let details = current.order_item_details;
if (typeof details === "string") {
try { details = JSON.parse(details); } catch (_) { details = null; }
}
const invoice = details && details.invoice;
const billed = invoice && (invoice.qbInvoiceId || invoice.qbInvoiceLink || invoice.billingCompany);
const downstream = ["qty_invoiced", "qty_delivered", "qty_stocked", "qty_picked", "qty_loaded", "surplus_qty"]
.some((field) => Number(current[field] || 0) > 0);
if (current.item_status !== "collected" || downstream || billed || current.customer_invoice_number ||
current.delivery_note_number || current.invoiced_date || current.delivered_at || current.stock_booked_from) {
throw new BadRequestError("This item has moved on and cannot be returned to the collection pool.");
}
} else {
let parent = current;
if (collection === "stock_purchase_items") {
if (current.status !== "collected" || !current.purchase) throw new BadRequestError("Stock item is no longer collected.");
parent = JSON.parse(JSON.stringify(e.app.findRecordById("stock_purchases", current.purchase)));
if (!guard.parent_updated || parent.updated !== guard.parent_updated) throw new BadRequestError("Stock PO changed. Refresh before returning it.");
}
if (parent.booked_at || ["sent", "received"].indexOf(parent.status) === -1) {
throw new BadRequestError("Stock PO is booked or no longer open for collection.");
}
refType = "stock_purchase";
refId = parent.id;
}
const ledger = e.app.findRecordsByFilter("stock_ledger",
"reference_type = {:type} && reference_id = {:id}", "", 1, 0,
{ type: refType, id: refId });
if (ledger.length) throw new BadRequestError("A stock booking exists; collection cannot be reset.");
return e.next();
}, "order_items", "stock_purchase_items", "stock_purchases");
// Booking writes the ledger before inventory. If it read the old received
// quantities before a return, reject its first write after the PO reopens.
// Conversely, a committed ledger entry makes the return guard refuse.
onRecordCreateRequest((e) => {
if (e.record.getString("reference_type") !== "stock_purchase") return e.next();
const originalApp = e.app;
try {
return originalApp.runInTransaction((txApp) => {
e.app = txApp;
const purchase = JSON.parse(JSON.stringify(txApp.findRecordById("stock_purchases", e.record.getString("reference_id"))));
if (purchase.status !== "received" || purchase.booked_at) {
throw new BadRequestError("Stock PO is no longer awaiting booking. Refresh before trying again.");
}
return e.next();
});
} finally { e.app = originalApp; }
}, "stock_ledger");

View file

@ -72,12 +72,12 @@ function setupFetch(opts: { confirmOk: boolean }) {
});
}
async function openConfirmAndAdd() {
async function openConfirm() {
const user = userEvent.setup();
render(<Page />);
await waitFor(() => expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument());
await user.click(screen.getByTestId("po-card-toggle-PO-1"));
await user.click(screen.getByTestId("po-card-confirm-add-pos-PO-1"));
await user.click(screen.getByTestId("po-card-confirm-PO-1"));
const dialog = await screen.findByRole("dialog");
// Pre-filled from ai_check.
expect(within(dialog).getByLabelText(/Supplier invoice number/i)).toHaveValue("5028256");
@ -94,20 +94,19 @@ describe("CollectionConfirmPage AI handlers", () => {
addResults = [{ row_id: "row-a", status: "created", stock_item_id: "s" }];
});
it("toasts added / failed / need-a-SKU counts after Add to POS", async () => {
addResults = [
{ row_id: "row-a", status: "created", stock_item_id: "s" },
{ row_id: "row-c", status: "failed", stock_item_id: null, error: "boom" },
{ row_id: "row-x", status: "needs_sku", stock_item_id: null },
];
it("hides Add to POS and keeps normal confirmation", async () => {
setupFetch({ confirmOk: true });
await openConfirmAndAdd();
await waitFor(() => expect(toastMock.warning).toHaveBeenCalledWith("1 added, 1 failed, 1 needs a SKU"));
const user = userEvent.setup();
render(<Page />);
await screen.findByTestId("po-card-PO-1");
await user.click(screen.getByTestId("po-card-toggle-PO-1"));
expect(screen.queryByRole("button", { name: /add to pos/i })).not.toBeInTheDocument();
expect(screen.getByTestId("po-card-confirm-PO-1")).toBeInTheDocument();
});
it("does not add to the POS when the confirm fails", async () => {
const spy = setupFetch({ confirmOk: false });
await openConfirmAndAdd();
await openConfirm();
await waitFor(() =>
expect(spy.mock.calls.some(([a]) => fetchUrl(a) === "/api/v1/order-items/confirm")).toBe(true),
);
@ -115,17 +114,55 @@ describe("CollectionConfirmPage AI handlers", () => {
expect(addCalls(spy)).toHaveLength(0);
});
it("adds only the not-in-POS customer rows, once, after the confirm succeeds", async () => {
it("does not add to POS after normal confirmation succeeds", async () => {
const spy = setupFetch({ confirmOk: true });
await openConfirmAndAdd();
await waitFor(() => expect(addCalls(spy)).toHaveLength(1));
const [, init] = addCalls(spy)[0];
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ row_ids: ["row-a", "row-c"] });
expect((init as RequestInit).headers).toMatchObject({ Authorization: "Bearer tok-123" });
const order = spy.mock.calls.map(([a]) => fetchUrl(a));
expect(order.indexOf("/api/v1/order-items/confirm")).toBeLessThan(
order.indexOf("/api/v1/collection-confirm/add-to-pos"),
);
await openConfirm();
await waitFor(() => expect(toastMock.success).toHaveBeenCalled());
expect(addCalls(spy)).toHaveLength(0);
});
it.each([false, true])("returns the selected line or PO and refreshes the queue (whole PO: %s)", async (wholePo) => {
const spy = setupFetch({ confirmOk: true });
let returned = false;
const base = spy.getMockImplementation()!;
spy.mockImplementation(async (input, init) => {
if (fetchUrl(input) === "/api/v1/collection-confirm/return-to-pool") {
returned = true;
return jsonResponse({ returned: wholePo ? rows : [rows[0]], skipped: [] });
}
if (fetchUrl(input) === "/api/v1/collection-confirm" && returned) {
return jsonResponse({ items: wholePo ? [] : rows.slice(1) });
}
return base(input, init);
});
const user = userEvent.setup();
render(<Page />);
await screen.findByTestId("po-card-PO-1");
await user.click(screen.getByTestId("po-card-toggle-PO-1"));
await user.click(screen.getByTestId(wholePo ? "po-card-return-pool-PO-1" : "return-to-pool-row-a"));
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: wholePo ? "Return 3 items" : "Return 1 item" }));
await waitFor(() => expect(screen.queryByTestId("items-row-row-a")).not.toBeInTheDocument());
const call = spy.mock.calls.find(([input]) => fetchUrl(input) === "/api/v1/collection-confirm/return-to-pool")!;
expect(JSON.parse(String(call[1]?.body))).toEqual({ rows: (wholePo ? rows : [rows[0]]).map(row => ({ id: row.id, source: "customer" })) });
expect(call[1]?.headers).toMatchObject({ Authorization: "Bearer tok-123" });
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining("Ready for driver assignment"));
});
it("keeps the reset dialog open when the server refuses the return", async () => {
const spy = setupFetch({ confirmOk: true });
const base = spy.getMockImplementation()!;
spy.mockImplementation(async (input, init) => fetchUrl(input) === "/api/v1/collection-confirm/return-to-pool"
? jsonResponse({ returned: [], skipped: [{ id: "row-a", reason: "Already invoiced" }] }, { ok: false, status: 409 })
: base(input, init));
const user = userEvent.setup();
render(<Page />);
await screen.findByTestId("po-card-PO-1");
await user.click(screen.getByTestId("po-card-toggle-PO-1"));
await user.click(screen.getByTestId("return-to-pool-row-a"));
await user.click(screen.getByRole("button", { name: "Return 1 item" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Already invoiced");
expect(screen.getByTestId("items-row-row-a")).toBeInTheDocument();
});
it("Check with AI posts { po_number } with the Bearer header", async () => {

View file

@ -454,6 +454,34 @@ export default function CollectionConfirmPage() {
[authedJsonHeaders, load],
);
const handleReturnToPool = useCallback(async (selected: ConfirmRow[]) => {
const response = await fetch("/api/v1/collection-confirm/return-to-pool", {
method: "POST",
headers: authedJsonHeaders(),
body: JSON.stringify({ rows: selected.map(row => ({ id: row.id, source: row.source === "stock" ? "stock" : "customer" })) }),
});
const data = await response.json().catch(() => ({})) as {
returned?: Array<{ id: string }>;
skipped?: Array<{ id: string; reason: string }>;
error?: string;
};
if (!response.ok) {
await load({ silent: true });
throw new Error(data.error || data.skipped?.map(row => row.reason).join(" ") || "Could not return these items.");
}
const count = data.returned?.length ?? 0;
toast.success(`Returned ${count} item${count === 1 ? "" : "s"} to the collection pool. Ready for driver assignment.`);
if (data.skipped?.length) {
toast.warning(`${data.skipped.length} item(s) could not be returned: ${data.skipped.map(row => row.reason).join(" ")}`);
}
setCostOverrides(previous => {
const next = { ...previous };
for (const row of data.returned ?? []) delete next[row.id];
return next;
});
await load({ silent: true });
}, [authedJsonHeaders, load]);
const handleCheckWithAi = useCallback(
async (poNumber: string) => {
try {
@ -839,6 +867,7 @@ export default function CollectionConfirmPage() {
onSkuChange={(id, sku) => void handleSkuChange(id, sku)}
onQtyChange={(id, qty) => void handleQtyChange(id, qty)}
onClearRows={(payload) => handleClearRows(group, payload)}
onReturnToPool={handleReturnToPool}
onAddToPos={(rowId) => void handleAddToPos([rowId])}
onCheckWithAi={() => handleCheckWithAi(group.poNumber)}
onMovePhoto={handleMovePhoto}

View file

@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/server/browser-session", () => ({ getBrowserSessionUser: vi.fn() }));
vi.mock("@/lib/server/collection-return-service", () => ({ returnCollectedToPool: vi.fn() }));
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { returnCollectedToPool } from "@/lib/server/collection-return-service";
import { POST } from "./route";
const rows = [{ id: "item1", source: "customer" as const }, { id: "stock1", source: "stock" as const }];
const req = (body: unknown) => new NextRequest("http://localhost/api/v1/collection-confirm/return-to-pool", {
method: "POST", body: JSON.stringify(body), headers: { Authorization: "Bearer token", "content-type": "application/json" },
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getBrowserSessionUser).mockResolvedValue({ id: "buyer", name: "Buyer", user_type: [] });
vi.mocked(returnCollectedToPool).mockResolvedValue({ returned: rows, skipped: [] });
});
describe("POST collection-confirm/return-to-pool", () => {
it("requires a real browser session", async () => {
vi.mocked(getBrowserSessionUser).mockRejectedValue(Object.assign(new Error("Not authenticated."), { status: 401 }));
expect((await POST(req({ rows }))).status).toBe(401);
expect(returnCollectedToPool).not.toHaveBeenCalled();
});
it.each([null, {}, { rows: [] }, { rows: [{ id: "x", source: "orders" }] }, { rows: [null] }, { rows: [{ id: " ", source: "stock" }] }, { rows: Array(501).fill(rows[0]) }])("rejects invalid selection %#", async (body) => {
expect((await POST(req(body))).status).toBe(400);
expect(returnCollectedToPool).not.toHaveBeenCalled();
});
it("uses the authenticated actor, never an actor supplied in the body", async () => {
const res = await POST(req({ rows, actor: { id: "forged" } }));
expect(res.status).toBe(200);
expect(returnCollectedToPool).toHaveBeenCalledWith(rows, { id: "buyer", name: "Buyer" });
expect(await res.json()).toEqual({ returned: rows, skipped: [] });
});
it("reports refused selections as conflicts", async () => {
vi.mocked(returnCollectedToPool).mockResolvedValue({ returned: [], skipped: [{ ...rows[0], reason: "Already invoiced" }] });
const res = await POST(req({ rows }));
expect(res.status).toBe(409);
expect((await res.json()).skipped[0].reason).toBe("Already invoiced");
});
it("does not leak backend errors", async () => {
vi.mocked(returnCollectedToPool).mockRejectedValue(new Error("internal secret"));
const res = await POST(req({ rows }));
expect(res.status).toBe(500);
expect(JSON.stringify(await res.json())).not.toContain("internal secret");
});
});

View file

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { returnCollectedToPool, type CollectionReturnRow } from "@/lib/server/collection-return-service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
try {
const user = await getBrowserSessionUser(req);
const body = await req.json().catch(() => null) as { rows?: unknown } | null;
const rows = body?.rows;
if (!Array.isArray(rows) || !rows.length || rows.length > 500 || rows.some(r =>
!r || typeof r.id !== "string" || !r.id.trim() || !["customer", "stock"].includes(r.source),
)) {
return NextResponse.json({ error: "Choose between 1 and 500 collected items." }, { status: 400 });
}
const result = await returnCollectedToPool(rows as CollectionReturnRow[], { id: user.id, name: user.name });
return NextResponse.json(result, { status: result.returned.length ? 200 : 409 });
} catch (err) {
const status = (err as { status?: number }).status || 500;
if (status >= 500) console.error("[collection-confirm/return-to-pool]", err);
return NextResponse.json({ error: status >= 500 ? "Could not return these items. Refresh and try again." : err instanceof Error ? err.message : "Return failed." }, { status });
}
}

View file

@ -7,6 +7,7 @@ import {
CheckCircle2,
MinusCircle,
Pencil,
Undo2,
XCircle,
} from "lucide-react";
import { useState } from "react";
@ -35,6 +36,7 @@ type Props = {
onQtyChange?: (id: string, qty: number) => void;
/** "Add to POS" click for a line that is not in the POS yet. */
onAddToPos?: (rowId: string) => void;
onReturnToPool?: (row: ConfirmRow) => void;
/**
* Force the AI + POS columns on or off. By default the AI column shows
* only when some row carries an `ai_check`, and the POS column when some
@ -213,6 +215,7 @@ export function ItemsTable({
onSkuChange,
onQtyChange,
onAddToPos,
onReturnToPool,
showAi,
}: Props) {
const aiColumn = showAi ?? rows.some((r) => !!r.ai_check);
@ -413,6 +416,17 @@ export function ItemsTable({
<span className="line-clamp-2">
{row.description ?? row.po_description ?? "—"}
</span>
{onReturnToPool ? (
<button
type="button"
className="mt-1 inline-flex min-h-8 items-center gap-1 rounded px-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onReturnToPool(row)}
aria-label={`Return ${row.description || row.sku || row.id} to collection pool`}
data-testid={`return-to-pool-${row.id}`}
>
<Undo2 className="size-3.5" aria-hidden /> Return to pool
</button>
) : null}
{/* AI ticks and POS status sit under the description, not in
extra columns: the table shares its pane with the document
preview, and trailing columns ended up hidden behind it. */}

View file

@ -214,13 +214,13 @@ describe("PoCard AI layer", () => {
await waitFor(() => expect(btn).not.toBeDisabled());
});
it("renders Confirm and add to POS only when a customer row is not in POS", async () => {
it("hides the POS actions even when their handlers are supplied", () => {
const onGo = vi.fn();
const { rerender } = render(
<PoCard rows={[makeAiRow({ in_pos: false })]} {...props} onConfirmAndAddToPos={onGo} />,
<PoCard rows={[makeAiRow({ in_pos: false })]} {...props} onConfirmAndAddToPos={onGo} onAddToPos={onGo} />,
);
await userEvent.click(screen.getByRole("button", { name: /confirm and add to pos/i }));
expect(onGo).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: /add to pos/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /^confirm for invoicing$/i })).toBeInTheDocument();
rerender(
<PoCard
@ -231,4 +231,25 @@ describe("PoCard AI layer", () => {
);
expect(screen.queryByRole("button", { name: /confirm and add to pos/i })).not.toBeInTheDocument();
});
it("returns one selected line only after confirming the reset", async () => {
const onReturnToPool = vi.fn().mockResolvedValue(undefined);
const row = makeAiRow({ id: "chosen", source: "stock" });
render(<PoCard rows={[row, { ...plain, id: "other" }]} {...props} onReturnToPool={onReturnToPool} />);
await userEvent.click(screen.getByTestId("return-to-pool-chosen"));
expect(onReturnToPool).not.toHaveBeenCalled();
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveTextContent("Collected quantities will be reset to zero");
await userEvent.click(screen.getByRole("button", { name: "Return 1 item" }));
expect(onReturnToPool).toHaveBeenCalledWith([row]);
});
it("offers all the card's collected rows in the PO footer and allows cancelling", async () => {
const onReturnToPool = vi.fn();
render(<PoCard rows={[plain, { ...plain, id: "second", source: "stock" }]} {...props} onReturnToPool={onReturnToPool} />);
await userEvent.click(screen.getByTestId("po-card-return-pool-PO-1"));
expect(screen.getByRole("button", { name: "Return 2 items" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onReturnToPool).not.toHaveBeenCalled();
});
});

View file

@ -26,6 +26,7 @@ import { InvoiceViewer } from "./invoice-viewer";
import { ItemsTable } from "./items-table";
import { ClearPanel, type ClearPayload } from "./clear-panel";
import type { Attachment } from "./photo-strip";
import { ReturnToPoolDialog } from "./return-to-pool-dialog";
/**
* Viewport-width probe — returns `true` when the viewport is ≥
@ -141,6 +142,8 @@ type Props = {
onCheckWithAi?: () => Promise<void>;
/** Confirm for invoicing, then add every not-in-POS customer line to the POS. */
onConfirmAndAddToPos?: () => void;
/** Return only these collected rows to the pool for a fresh driver assignment. */
onReturnToPool?: (rows: ConfirmRow[]) => Promise<void>;
/** Delete a wrongly uploaded or duplicate photo (the one open in the previewer). */
onDeletePhoto?: (target: NonNullable<Attachment["deleteTarget"]>) => Promise<void>;
/** Move a driver photo whose invoice names another PO onto that PO. */
@ -200,13 +203,13 @@ export function PoCard({
onPopOut,
onFullscreen,
onInvoiceUpload,
onAddToPos,
onCheckWithAi,
onConfirmAndAddToPos,
onReturnToPool,
onMovePhoto,
onDeletePhoto,
}: Props) {
const itemCount = rows.length;
const [returnRows, setReturnRows] = useState<ConfirmRow[] | null>(null);
const [checkingAi, setCheckingAi] = useState(false);
const [docPanelOpen, setDocPanelOpen] = useState(false);
const aiDoc = useMemo(() => cardDocument(rows), [rows]);
@ -245,7 +248,6 @@ export function PoCard({
: [...paired.values()].sort((a, b) => a.line_no - b.line_no);
return { aiDocLines: all, matchedLineNos: new Set(paired.keys()) };
}, [rows, aiDoc]);
const hasUnpostedCustomerRows = rows.some((r) => r.source !== "stock" && !r.in_pos);
const aiDocPanel =
docPanelOpen && aiDoc ? (
<DocumentLinesPanel poNumber={poNumber} lines={aiDocLines} matchedLineNos={matchedLineNos} />
@ -893,7 +895,7 @@ export function PoCard({
onCostChange={onCostChange}
onSkuChange={onSkuChange}
onQtyChange={onQtyChange}
onAddToPos={onAddToPos}
onReturnToPool={onReturnToPool ? (row) => setReturnRows([row]) : undefined}
/>
</div>
</div>
@ -960,7 +962,7 @@ export function PoCard({
onCostChange={onCostChange}
onSkuChange={onSkuChange}
onQtyChange={onQtyChange}
onAddToPos={onAddToPos}
onReturnToPool={onReturnToPool ? (row) => setReturnRows([row]) : undefined}
/>
</div>
@ -1021,11 +1023,11 @@ export function PoCard({
<AiFindings findings={aiFindings} notes={aiNotes} />
<footer className="flex items-center justify-between gap-2">
<footer className="flex flex-wrap items-center justify-between gap-2">
<p className="hidden text-[11px] text-muted-foreground lg:block">
Drag the divider to give the invoice more room.
</p>
<div className="ml-auto flex gap-2">
<div className="ml-auto flex flex-wrap justify-end gap-2">
{onCheckWithAi ? (
<Button
type="button"
@ -1067,19 +1069,23 @@ export function PoCard({
Confirm for invoicing
</Button>
) : null}
{hasCustomerRows && hasUnpostedCustomerRows && onConfirmAndAddToPos ? (
{onReturnToPool ? (
<Button
type="button"
onClick={onConfirmAndAddToPos}
data-testid={`po-card-confirm-add-pos-${poNumber}`}
variant="outline"
onClick={() => setReturnRows(rows.slice())}
data-testid={`po-card-return-pool-${poNumber}`}
>
Confirm and add to POS
Return PO to collection pool
</Button>
) : null}
</div>
</footer>
</div>
) : null}
{returnRows && onReturnToPool ? (
<ReturnToPoolDialog rows={returnRows} poNumber={poNumber} onClose={() => setReturnRows(null)} onSubmit={onReturnToPool} />
) : null}
</section>
);
}

View file

@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ModalShell } from "@/components/ui/modal-shell";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
export function ReturnToPoolDialog({ rows, poNumber, onClose, onSubmit }: {
rows: ConfirmRow[];
poNumber: string;
onClose: () => void;
onSubmit: (rows: ConfirmRow[]) => Promise<void>;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const count = rows.length;
return (
<ModalShell.Dialog
open
onOpenChange={(open) => { if (!open && !busy) onClose(); }}
size="md"
eyebrow={poNumber}
title="Return to collection pool?"
description={`Return ${count} collected item${count === 1 ? "" : "s"} for another pickup.`}
footer={
<div className="flex justify-end gap-2">
<Button variant="outline" disabled={busy} onClick={onClose}>Cancel</Button>
<Button disabled={busy} onClick={async () => {
setBusy(true);
setError(null);
try {
await onSubmit(rows);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not return these items.");
} finally { setBusy(false); }
}}>
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
{busy ? "Returning…" : `Return ${count} item${count === 1 ? "" : "s"}`}
</Button>
</div>
}
>
<p className="text-sm text-muted-foreground">
Collected quantities will be reset to zero and driver assignments and collection dates cleared.
These items will be available to assign to a driver again. Photos and order details will be kept.
</p>
<ul className="max-h-48 space-y-1 overflow-auto text-sm">
{rows.map(row => <li key={`${row.source}:${row.id}`}>{row.description || row.po_description || row.sku || row.id}</li>)}
</ul>
<p className="text-xs text-muted-foreground">Items already invoiced, dispatched or booked into stock cannot be returned here.</p>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
</ModalShell.Dialog>
);
}

View file

@ -0,0 +1,114 @@
// Run the real PB hook in a JS sandbox; no live records are changed.
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { describe, expect, it, vi } from "vitest";
function setup(stored: Record<string, unknown> = {}, collection = "order_items") {
let hook!: (event: unknown) => void;
let capability!: (event: unknown) => void;
let createHook!: (event: unknown) => void;
const next = vi.fn();
const body = { _collection_return_guard: { updated: "v1", parent_updated: "p1" } };
const parent = { id: "po1", updated: "p1", status: "received", booked_at: "" };
const app = {
findRecordById: vi.fn((name: string) => name === "stock_purchases" ? parent : original),
findRecordsByFilter: vi.fn((): unknown[] => []),
runInTransaction: (fn: (tx: unknown) => void) => fn(app),
};
const info = { body, context: "batch" };
const original = { id: "row1", updated: "v1", item_status: "collected", status: "collected", purchase: "po1", ...stored };
const e = {
requestInfo: () => info, auth: { isSuperuser: () => true }, app, next,
record: { original: () => original, collection: () => ({ name: collection }), getString: (field: string) => String((original as Record<string, unknown>)[field] ?? "") },
};
const source = readFileSync("pocketbase/pb_hooks/collection_return.pb.js", "utf8");
runInNewContext(source, {
onRecordUpdateRequest: (callback: typeof hook) => { hook = callback; },
onRecordCreateRequest: (callback: typeof hook) => { createHook = callback; },
routerAdd: (_method: string, _path: string, callback: typeof capability) => { capability = callback; },
$apis: { requireSuperuserAuth: () => ({}) },
BadRequestError: Error, ForbiddenError: Error,
});
return { call: () => hook(e), create: () => createHook(e), e, next, app, info, parent, capability, original };
}
describe("PB collection return transaction guard", () => {
it("allows a matching collected revision inside a batch", () => {
const test = setup();
test.call();
expect(test.next).toHaveBeenCalledOnce();
});
it("leaves ordinary updates alone", () => {
const test = setup();
test.info.body = {} as typeof test.info.body;
test.call();
expect(test.next).toHaveBeenCalledOnce();
});
it.each([
{ updated: "v2" }, { item_status: "ready_invoice" }, { qty_delivered: 1 },
{ order_item_details: { invoice: { number: "INV1", qbInvoiceId: "qb1" } } },
{ order_item_details: JSON.stringify({ invoice: { number: "INV1", billingCompany: "CNL" } }) },
])("refuses a concurrent advance: %j", stored => {
const test = setup(stored);
expect(test.call).toThrow();
expect(test.next).not.toHaveBeenCalled();
});
it("refuses non-batch resets", () => {
const test = setup();
test.info.context = "default";
expect(test.call).toThrow();
});
it("refuses non-superusers", () => {
const test = setup();
test.e.auth.isSuperuser = () => false;
expect(test.call).toThrow();
});
it("accepts a supplier-invoice blob on a still-collected customer item", () => {
const test = setup({ order_item_details: { invoice: { number: "SUP1" } } });
test.call();
expect(test.next).toHaveBeenCalledOnce();
});
it("refuses a stock parent changed after the app's check", () => {
const test = setup({}, "stock_purchase_items");
test.parent.updated = "p2";
expect(test.call).toThrow();
});
it("refuses a stock booking that started after the app's check", () => {
const test = setup({}, "stock_purchase_items");
test.app.findRecordsByFilter.mockReturnValue([{ id: "new-receipt" }]);
expect(test.call).toThrow();
});
it("allows an unbooked received header to reopen", () => {
const test = setup({ status: "received" }, "stock_purchases");
test.call();
expect(test.next).toHaveBeenCalledOnce();
});
it("refuses a stock header booked between checking and saving", () => {
const test = setup({ status: "received", booked_at: "today" }, "stock_purchases");
expect(test.call).toThrow();
});
it("refuses stale confirmation after a reset has committed", () => {
const test = setup({ item_status: "collection_pool" });
test.info.body = { item_status: "ready_invoice" } as unknown as typeof test.info.body;
expect(test.call).toThrow();
expect(test.next).not.toHaveBeenCalled();
});
it("allows normal collection confirmation", () => {
const test = setup();
test.info.body = { item_status: "ready_invoice" } as unknown as typeof test.info.body;
test.call();
expect(test.next).toHaveBeenCalledOnce();
});
it("refuses a stale stock booking ledger insert after a reset reopens the PO", () => {
const test = setup({ reference_type: "stock_purchase", reference_id: "po1" });
test.parent.status = "sent";
expect(test.create).toThrow();
expect(test.next).not.toHaveBeenCalled();
});
it("allows a stock booking ledger insert while the PO is received", () => {
const test = setup({ reference_type: "stock_purchase", reference_id: "po1" });
test.create();
expect(test.next).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: vi.fn() }));
vi.mock("@/lib/server/orders-service", () => ({ getOrderItemsCollectionName: () => "order_items" }));
import { getAdminPocketBase } from "./pocketbase-admin";
import { returnCollectedToPool } from "./collection-return-service";
const actor = { id: "buyer", name: "Buyer" };
const customer = { source: "customer" as const, id: "customer1" };
const stock = { source: "stock" as const, id: "stock1" };
function setup(over: Record<string, unknown> = {}, head: Record<string, unknown> = {}) {
const patches: Array<{ collection: string; id: string; patch: Record<string, unknown> }> = [];
const send = vi.fn().mockResolvedValue([]);
const ledger = vi.fn().mockResolvedValue({ totalItems: 0, items: [] });
const pb = {
send: vi.fn().mockResolvedValue({ version: 1 }),
collection: vi.fn((name: string) => ({
getOne: vi.fn().mockResolvedValue(name === "stock_purchases"
? { id: "purchase1", status: "sent", updated: "version1", ...head }
: { id: name === "order_items" ? customer.id : stock.id, updated: "version1", item_status: "collected", status: "collected", purchase: "purchase1", drv_id: "driver", qty_collected: 105, quantity_received: 105, quantity: 100, ...over }),
getList: ledger,
})),
filter: (filter: string) => filter,
createBatch: () => ({
collection: (name: string) => ({ update: (id: string, patch: Record<string, unknown>) => patches.push({ collection: name, id, patch }) }),
send,
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
return { patches, send, ledger, capability: pb.send };
}
beforeEach(() => vi.clearAllMocks());
describe("returnCollectedToPool", () => {
it("resets customer collection and assignment without changing the order or deleting photos", async () => {
const { patches, send } = setup();
const result = await returnCollectedToPool([customer], actor);
expect(result.returned).toEqual([customer]);
expect(patches[0]).toMatchObject({ collection: "order_items", id: customer.id, patch: {
item_status: "collection_pool", drv_status: "unassigned", drv_id: "", assigned_at: "", drv_assigned_at: "", drv_collected_at: "",
qty_assigned: 0, qty_collected: 0, qty_processed: 0, qty_verified: 0,
collection_date: "", collected_date: "", ai_check: null, ready_for_collection: true,
problem_flag: false, problem_notes: "", problem_type: "",
} });
for (const key of ["quantity", "order_qty", "purchase_order", "order", "sku", "cost_price", "order_item_attachements", "notes"]) {
expect(patches[0].patch).not.toHaveProperty(key);
}
expect(send).toHaveBeenCalledOnce();
});
it("returns a stock line and reopens an unbooked received stock PO in the same batch", async () => {
const { patches } = setup({}, { status: "received" });
await returnCollectedToPool([stock], actor);
expect(patches).toEqual(expect.arrayContaining([
{ collection: "stock_purchase_items", id: stock.id, patch: expect.objectContaining({ status: "collection_pool", quantity_received: 0, drv_id: "", assigned_at: "", ai_check: null }) },
{ collection: "stock_purchases", id: "purchase1", patch: expect.objectContaining({ status: "sent" }) },
]));
});
it.each([
{ item_status: "invoiced" }, { item_status: "delivered" }, { item_status: "ready_invoice" },
{ qty_invoiced: 1 }, { qty_delivered: 1 }, { qty_stocked: 1 }, { surplus_qty: 5 },
{ customer_invoice_number: "INV1" }, { delivery_note_number: "DN1" },
{ order_item_details: { invoice: { number: "INV1", qbInvoiceId: "q1" } } },
{ order_item_details: JSON.stringify({ invoice: { number: "INV1", billingCompany: "CNL" } }) },
])("refuses a customer row that has moved on: %j", async (over) => {
const { patches, send } = setup(over);
const result = await returnCollectedToPool([customer], actor);
expect(result.returned).toEqual([]);
expect(result.skipped).toHaveLength(1);
expect(patches).toEqual([]);
expect(send).not.toHaveBeenCalled();
});
it.each([{ status: "booked" }, { status: "assigned_to_driver" }])("refuses stock not collected: %j", async (over) => {
const { patches } = setup(over);
expect((await returnCollectedToPool([stock], actor)).skipped).toHaveLength(1);
expect(patches).toEqual([]);
});
it.each([{ booked_at: "2026-10-21" }, { status: "cancelled" }])("refuses a booked or cancelled stock PO: %j", async (head) => {
const { patches } = setup({}, head);
expect((await returnCollectedToPool([stock], actor)).skipped).toHaveLength(1);
expect(patches).toEqual([]);
});
it("refuses rows with stock-ledger entries even if their status is stale", async () => {
const { ledger, patches } = setup();
ledger.mockResolvedValue({ totalItems: 1, items: [{ id: "receipt" }] });
expect((await returnCollectedToPool([customer, stock], actor)).skipped).toHaveLength(2);
expect(patches).toEqual([]);
});
it("deduplicates rows and reports refused rows alongside the returned ones", async () => {
const { patches } = setup({ item_status: "invoiced" });
const result = await returnCollectedToPool([customer, stock, stock], actor);
expect(result.returned).toEqual([stock]);
expect(result.skipped).toEqual([expect.objectContaining(customer)]);
expect(patches.filter(p => p.collection === "stock_purchase_items")).toHaveLength(1);
});
it("does not report success if the atomic batch fails", async () => {
const { send } = setup();
send.mockRejectedValue(new Error("batch failed"));
await expect(returnCollectedToPool([customer, stock], actor)).rejects.toThrow("batch failed");
});
it("reports a transaction guard refusal as a refreshable conflict", async () => {
const { send } = setup();
send.mockRejectedValue(Object.assign(new Error("Batch transaction failed"), { status: 400 }));
await expect(returnCollectedToPool([customer], actor)).rejects.toMatchObject({ status: 409, message: expect.stringContaining("No items were returned") });
});
it("fails closed without the deployed transaction guard", async () => {
const { capability, send } = setup();
capability.mockRejectedValue(Object.assign(new Error("Not found"), { status: 404 }));
await expect(returnCollectedToPool([customer], actor)).rejects.toMatchObject({ status: 503 });
expect(send).not.toHaveBeenCalled();
});
it("sends the checked row revision to the transaction guard", async () => {
const { patches } = setup();
await returnCollectedToPool([customer, stock], actor);
expect(patches[0].patch._collection_return_guard).toEqual({ updated: "version1" });
expect(patches[1].patch._collection_return_guard).toEqual({ updated: "version1", parent_updated: "version1" });
});
});

View file

@ -0,0 +1,122 @@
import { getAdminPocketBase } from "./pocketbase-admin";
import { getOrderItemsCollectionName } from "./orders-service";
import { runWithAuditActor, type AuditActor } from "./audit-context";
import { readItemInvoiceNumber } from "@/lib/order-invoices";
export type CollectionReturnRow = { id: string; source: "customer" | "stock" };
export type CollectionReturnResult = {
returned: CollectionReturnRow[];
skipped: Array<CollectionReturnRow & { reason: string }>;
};
/** Return only the collected rows explicitly selected on Collection Confirm.
* Keep order quantities, pricing, photos and notes. PB hooks record the changes.
* The batch makes line resets and reopening any stock header all-or-nothing.
*/
export async function returnCollectedToPool(
rows: CollectionReturnRow[],
actor: AuditActor,
): Promise<CollectionReturnResult> {
if (!Array.isArray(rows) || rows.length === 0 || rows.length > 500 || rows.some(r =>
!r || typeof r.id !== "string" || !r.id.trim() || !["customer", "stock"].includes(r.source),
)) {
throw Object.assign(new Error("Choose between 1 and 500 collected items."), { status: 400 });
}
return runWithAuditActor({ ...actor, source: "web" }, async () => {
const pb = await getAdminPocketBase();
// Unknown PB fields are silently dropped. Never send the guarded batch
// unless the hook that enforces its revision checks is actually deployed.
try {
const capability = await pb.send<{ version: number }>("/api/wfdash/collection-return-guard", { method: "GET" });
if (capability.version !== 1) throw new Error("Unsupported guard version");
} catch {
throw Object.assign(new Error("Return to pool is not available until the PocketBase collection-return guard is deployed."), { status: 503 });
}
const batch = pb.createBatch();
const result: CollectionReturnResult = { returned: [], skipped: [] };
const seen = new Set<string>();
const reopen = new Map<string, string>();
for (const row of rows) {
const key = `${row.source}:${row.id}`;
if (seen.has(key)) continue;
seen.add(key);
const collection = row.source === "stock" ? "stock_purchase_items" : getOrderItemsCollectionName();
let current;
try {
current = await pb.collection(collection).getOne(row.id);
} catch (err) {
if ((err as { status?: number }).status !== 404) throw err;
result.skipped.push({ ...row, reason: "Item no longer exists." });
continue;
}
const refuse = (reason: string) => result.skipped.push({ ...row, reason });
const status = row.source === "stock" ? current.status : current.item_status;
if (status !== "collected") {
refuse(`Item is ${status || "no longer collected"}; refresh before trying again.`);
continue;
}
let purchase;
if (row.source === "customer") {
const advancedQty = ["qty_invoiced", "qty_delivered", "qty_stocked", "qty_picked", "qty_loaded", "surplus_qty"]
.some(field => Number(current[field] ?? 0) > 0);
if (advancedQty || readItemInvoiceNumber(current) || current.delivery_note_number || current.invoiced_date || current.delivered_at || current.stock_booked_from) {
refuse("Item has already been invoiced, dispatched or booked into stock.");
continue;
}
} else {
if (!current.purchase) {
refuse("Stock item has no purchase order.");
continue;
}
purchase = await pb.collection("stock_purchases").getOne(current.purchase);
if (purchase.booked_at || !["sent", "received"].includes(purchase.status)) {
refuse("Stock purchase is already booked or is no longer open for collection.");
continue;
}
}
// Ledger entries survive some interrupted bookings; do not trust status alone.
const ledger = await pb.collection("stock_ledger").getList(1, 1, {
filter: pb.filter("reference_type = {:type} && reference_id = {:id}", {
type: row.source === "stock" ? "stock_purchase" : "order_item",
id: row.source === "stock" ? current.purchase : current.id,
}),
fields: "id", requestKey: null,
});
if (ledger.totalItems > 0) {
refuse("A stock booking already exists; collection cannot be reset.");
continue;
}
const common = {
drv_id: "", assigned_at: "", problem_flag: false, problem_type: "", problem_notes: "", ai_check: null,
_collection_return_guard: { updated: current.updated, ...(purchase ? { parent_updated: purchase.updated } : {}) },
};
batch.collection(collection).update(row.id, row.source === "stock" ? {
...common, status: "collection_pool", quantity_received: 0,
} : {
...common, item_status: "collection_pool", drv_status: "unassigned",
drv_assigned_at: "", drv_collected_at: "", collection_date: "", collected_date: "",
qty_assigned: 0, qty_collected: 0, qty_processed: 0, qty_verified: 0,
qty_picked: 0, qty_loaded: 0, qty_stocked: 0, qty_delivered: 0, qty_invoiced: 0,
verified_date: "", ready_for_collection: true, pool_entered_at: new Date().toISOString(),
});
if (purchase?.status === "received") reopen.set(purchase.id, purchase.updated);
result.returned.push(row);
}
for (const [id, updated] of reopen) batch.collection("stock_purchases").update(id, { status: "sent", _collection_return_guard: { updated } });
if (result.returned.length) {
try {
await batch.send();
} catch (err) {
if ((err as { status?: number }).status === 400) {
throw Object.assign(new Error("The reset could not be saved; an item may have changed or moved on. No items were returned. Refresh and try again."), { status: 409 });
}
throw err;
}
}
return result;
});
}