From ebc045a145a29d7bb1cf91605d2e8e97e4043745 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 21 Oct 2026 03:37:01 +0200 Subject: [PATCH] feat(collection): return mistaken pickups to pool --- DEPLOY | 1 + collection_return.pb.js | 94 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 collection_return.pb.js diff --git a/DEPLOY b/DEPLOY index 2180c78..eccd6f3 100644 --- a/DEPLOY +++ b/DEPLOY @@ -8,3 +8,4 @@ quotes.pb.js audit_log_utils.js audit_log.pb.js +collection_return.pb.js diff --git a/collection_return.pb.js b/collection_return.pb.js new file mode 100644 index 0000000..763a432 --- /dev/null +++ b/collection_return.pb.js @@ -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");