feat(collection): return mistaken pickups to pool

This commit is contained in:
Dave 2026-10-21 03:37:01 +02:00
parent dde816f676
commit ebc045a145
2 changed files with 95 additions and 0 deletions

1
DEPLOY
View file

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

94
collection_return.pb.js Normal file
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");