feat(collection): return mistaken pickups to pool
This commit is contained in:
parent
42a20d7e72
commit
511f5dac6e
14 changed files with 788 additions and 36 deletions
51
docs/collection-return-to-pool.md
Normal file
51
docs/collection-return-to-pool.md
Normal 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.
|
||||
|
|
@ -8,3 +8,4 @@
|
|||
quotes.pb.js
|
||||
audit_log_utils.js
|
||||
audit_log.pb.js
|
||||
collection_return.pb.js
|
||||
|
|
|
|||
94
pocketbase/pb_hooks/collection_return.pb.js
Normal file
94
pocketbase/pb_hooks/collection_return.pb.js
Normal 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");
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
25
src/app/api/v1/collection-confirm/return-to-pool/route.ts
Normal file
25
src/app/api/v1/collection-confirm/return-to-pool/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
56
src/components/collection-confirm/return-to-pool-dialog.tsx
Normal file
56
src/components/collection-confirm/return-to-pool-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/lib/server/collection-return-guard.test.ts
Normal file
114
src/lib/server/collection-return-guard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
130
src/lib/server/collection-return-service.test.ts
Normal file
130
src/lib/server/collection-return-service.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
122
src/lib/server/collection-return-service.ts
Normal file
122
src/lib/server/collection-return-service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue