/** * 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");