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})
)}