From b43ad41ab4e9f57f90597a4e970c681fbd456c61 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 23 Sep 2026 08:29:28 +0200 Subject: [PATCH] feat(orders): issue stock-replenishment PO on Mark-in-stock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markInStockReady on the order-process page now creates a real purchase_orders row + v1 PDF for the in-stock subset, addressed to the supplier field on those lines, alongside the existing ready_invoice + qty_collected flip. The item lifecycle is unchanged: item_status still lands at ready_invoice, so no driver gets dispatched. The PO is purely a paper trail the internal stock supplier can invoice us against. Mirrors sendPoForSupplier's create + PDF flow: status='draft', order_item back-linked, total_amount = Σ cost_price * (order_qty ?? quantity), then POST /api/v1/purchase-orders/[id]/pdf to render v1 + flip to 'sent'. Pre-existing POs the lines used to belong to are still trimmed + auto-cancelled when empty. UI: button label '{supplier} in-stock (n)' -> '{supplier} stock PO (n)'; Send POs card help text updated to mention the new PO. --- src/app/(app)/orders/[id]/process/page.tsx | 172 +++++++++++++++++---- 1 file changed, 142 insertions(+), 30 deletions(-) diff --git a/src/app/(app)/orders/[id]/process/page.tsx b/src/app/(app)/orders/[id]/process/page.tsx index 136ad13..d89a934 100644 --- a/src/app/(app)/orders/[id]/process/page.tsx +++ b/src/app/(app)/orders/[id]/process/page.tsx @@ -1407,21 +1407,36 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str /** * Bulk-flip the in-stock editable lines for one supplier to - * `ready_invoice`. The companion to `sendPoForSupplier` — - * together they split the Send POs card into two per-supplier - * buttons. The in-stock subset can already have been unlinked - * from any draft PO by the per-row `toggleInStock`; this - * handler defensively handles the case where some still carry a - * `purchase_order` (e.g. operator toggled in-stock then - * `Mark in-stock ready` before the auto-unlink finished its - * toast cycle). + * `ready_invoice`, and issue a real stock-replenishment PO + * addressed to that supplier for those lines. The companion to + * `sendPoForSupplier` — together they split the Send POs card + * into two per-supplier buttons. The in-stock subset can already + * have been unlinked from any draft PO by the per-row + * `toggleInStock`; this handler defensively handles the case + * where some still carry a `purchase_order` (e.g. operator + * toggled in-stock then "stock PO" before the auto-unlink + * finished its toast cycle). + * + * Why a PO for in-stock items at all? + * The supplier field on an in-stock line still points at a + * real company (the internal stock owner). The operator wants + * a paper PO + PDF that can be downloaded / emailed to that + * supplier so they can invoice us for the replenishment, + * without sending a driver out to collect it. The item itself + * still goes to `ready_invoice` (skips the driver pool — the + * goods are already on our shelves). * * Behaviour per line: - * - `purchase_order` cleared. + * - `purchase_order` set to the new PO id. * - `item_status` flipped to `ready_invoice`. - * Per affected PO: + * - `qty_collected` stamped to `quantity` so invoicing bills + * the full customer qty. + * Per pre-existing PO (caught before the line write): * - back-link trimmed. * - auto-`status: "cancelled"` if the back-link becomes empty. + * For the new PO: + * - created in `draft`, PDF rendered via the same route as a + * normal Send PO; on PDF success the PO flips to `sent`. */ const markInStockReady = async (supplierId: string) => { const supplier = suppliers.find((s) => s.id === supplierId); @@ -1440,6 +1455,36 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str setMarkingInStockSupplier(supplierId); const pb = getPocketBase(); try { + // ── Create the stock-replenishment PO ───────────────── + // Mirror of `sendPoForSupplier`'s create block, filtered on + // in_stock=true. Total uses `cost_price * order_qty ?? quantity` + // — matches the PDF route's subtotal so the PO record's + // total_amount agrees with what the rendered document shows. + const poNumber = await generatePONumber(id); + const total = inStock.reduce( + (sum, it) => + sum + numOrZero(it.cost_price) * numOrZero(it.order_qty ?? it.quantity), + 0, + ); + const po = await pb.collection("purchase_orders").create({ + po_number: poNumber, + order: id, + supplier: supplierId, + // `draft` — the PDF route flips to "sent" on v1 success. + // If the PDF step fails, the PO stays in draft so the + // operator can re-trigger from the order detail page's + // "Regenerate PO" button. Same contract as Send PO. + status: "draft", + total_amount: total, + order_item: inStock.map((it) => it.id), + }); + + // ── Per-line: link to the new PO + flip to ready_invoice ── + // The pre-existing `purchase_order` (if any) is captured in + // `affectedPoIds` BEFORE the line write overwrites it, so we + // still know which pre-existing POs to trim below. Mirrors + // `sendPoForSupplier`'s linkFailures handling — silent drops + // here are how operators end up with orphan POs. const affectedPoIds = new Map>(); const cancelledPoNumbers: string[] = []; let stockFailures = 0; @@ -1459,14 +1504,19 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str try { // In-stock items are goods that already sit on our // shelves — the operator is telling us "we have these, - // don't send a PO". The invoicing flow bills whatever's in - // `qty_collected`, so leaving qty_collected=0 would push a - // 0-line invoice to QuickBooks for every in-stock item. - // Stamp qty_collected=quantity here so the in-stock flip - // is treated as "fully received from our own warehouse" + // don't put a driver on the road". The invoicing flow + // bills whatever's in `qty_collected`, so leaving + // qty_collected=0 would push a 0-line invoice to + // QuickBooks for every in-stock item. Stamp + // qty_collected=quantity here so the in-stock flip is + // treated as "fully received from our own warehouse" // and the invoice line carries the customer-facing qty. + // `purchase_order` points at the new PO for the audit + // trail + PDF — but the item still skips the driver + // pool because `item_status` is `ready_invoice`, not + // `po_generated`. await pb.collection("order_items").update(it.id, { - purchase_order: "", + purchase_order: po.id, item_status: "ready_invoice", qty_collected: Number(it.quantity ?? 0), }); @@ -1476,17 +1526,21 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str linkErr instanceof Error ? linkErr.message : String(linkErr); stockErrors.push(`${it.description.slice(0, 30)}…: ${msg}`); console.error( - `[mark-in-stock] failed to flip item ${it.id} to ready_invoice:`, + `[mark-in-stock] failed to link item ${it.id} to stock PO ${po.id}:`, linkErr, ); } } + // ── Trim pre-existing POs the in-stock lines used to belong to ── + // Same defensive unlink as before — an in-stock item might + // still carry a `purchase_order` link from a prior Send PO + // or from a manual assignment before the in-stock flip. for (const [poId, unlinkedIds] of affectedPoIds.entries()) { try { - const po = await pb.collection("purchase_orders").getOne(poId); - const currentLinks: string[] = Array.isArray(po.order_item) - ? po.order_item.filter( + const poRow = await pb.collection("purchase_orders").getOne(poId); + const currentLinks: string[] = Array.isArray(poRow.order_item) + ? poRow.order_item.filter( (x): x is string => typeof x === "string", ) : []; @@ -1498,9 +1552,9 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str order_item: [], status: "cancelled", }); - cancelledPoNumbers.push(po.po_number || poId); + cancelledPoNumbers.push(poRow.po_number || poId); console.log( - `[mark-in-stock] auto-cancelled empty draft PO ${po.po_number || poId}`, + `[mark-in-stock] auto-cancelled empty draft PO ${poRow.po_number || poId} after stock PO ${po.id} took its lines`, ); } else { await pb.collection("purchase_orders").update(poId, { @@ -1515,22 +1569,78 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str } } + // ── Generate + attach v1 PDF ────────────────────────────── + // Same PDF route as Send PO. If linking failed for some + // lines, skip PDF generation so the operator doesn't get a + // confusing partial-state PDF; the PO survives as draft and + // the toast tells them what to retry. + let pdfInfo: { + pdfId?: string; + downloadUrl?: string; + version?: number; + } | null = null; if (stockFailures > 0) { const summary = stockErrors[0] ?? ""; toast.warning( - `${stockFailures}/${inStock.length} in-stock item(s) failed to flip to ready_invoice. Retry from the order page.`, + `Stock PO ${poNumber} created, but ${stockFailures}/${inStock.length} item(s) failed to link. The PDF has been skipped — re-link the items and resend from the order page.`, { description: summary, duration: 12000 }, ); + await fetchAll(); + return; } - if (cancelledPoNumbers.length > 0) { + try { + const pdfRes = await fetch( + `/api/v1/purchase-orders/${po.id}/pdf`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + // First send — no notes yet (v1 rarely needs a reason). + body: JSON.stringify({ notes: "" }), + }, + ); + if (pdfRes.ok) { + pdfInfo = await pdfRes.json(); + } else { + const errBody = await pdfRes.json().catch(() => ({})); + console.error( + "[mark-in-stock] PDF generation failed:", + errBody, + ); + toast.warning( + `Stock PO ${poNumber} created but PDF generation failed (${errBody.code || pdfRes.status}). Regenerate from the order page.`, + ); + } + } catch (pdfErr) { + console.error("[mark-in-stock] PDF fetch threw:", pdfErr); + } + + if (stockFailures === 0 && cancelledPoNumbers.length > 0) { toast.message( `Auto-cancelled empty draft PO(s): ${cancelledPoNumbers.join(", ")}.`, - { description: "All items were moved to in-stock or another supplier." }, + { + description: + "All items were moved to in-stock or another supplier.", + }, + ); + } + if (pdfInfo?.downloadUrl) { + toast.success( + `Stock PO ${poNumber} v${pdfInfo.version} sent to ${supplier.suppliername}`, + { + description: + "In-stock lines ready for invoicing; PDF available for the supplier.", + action: { + label: "Download", + onClick: () => + window.open(pdfInfo!.downloadUrl!, "_blank"), + }, + }, + ); + } else { + toast.success( + `${inStock.length - stockFailures} in-stock item(s) for ${supplier.suppliername} marked ready for invoicing.`, ); } - toast.success( - `${inStock.length - stockFailures} in-stock item(s) for ${supplier.suppliername} marked ready for invoicing.`, - ); await fetchAll(); } catch (err: unknown) { toast.error( @@ -1965,7 +2075,9 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str supplier PO + email for the non-in-stock lines, and{" "} {`{supplier} in-stock`}{" "} flips the in-stock lines straight to{" "} - ready for invoicing. + ready for invoicing{" "} + and issues a stock-replenishment PO + PDF for the supplier + (no driver collection).

{Object.entries(bySupplier).map(([supplierId, list]) => { @@ -2015,7 +2127,7 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str ) : ( )} - {name} in-stock ({inStockCount}) + {name} stock PO ({inStockCount}) )}