feat(orders): issue stock-replenishment PO on Mark-in-stock

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.
This commit is contained in:
Dave 2026-09-23 08:29:28 +02:00
parent d8e8714720
commit b43ad41ab4

View file

@ -1407,21 +1407,36 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
/** /**
* Bulk-flip the in-stock editable lines for one supplier to * Bulk-flip the in-stock editable lines for one supplier to
* `ready_invoice`. The companion to `sendPoForSupplier` — * `ready_invoice`, and issue a real stock-replenishment PO
* together they split the Send POs card into two per-supplier * addressed to that supplier for those lines. The companion to
* buttons. The in-stock subset can already have been unlinked * `sendPoForSupplier` — together they split the Send POs card
* from any draft PO by the per-row `toggleInStock`; this * into two per-supplier buttons. The in-stock subset can already
* handler defensively handles the case where some still carry a * have been unlinked from any draft PO by the per-row
* `purchase_order` (e.g. operator toggled in-stock then * `toggleInStock`; this handler defensively handles the case
* `Mark in-stock ready` before the auto-unlink finished its * where some still carry a `purchase_order` (e.g. operator
* toast cycle). * 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: * Behaviour per line:
* - `purchase_order` cleared. * - `purchase_order` set to the new PO id.
* - `item_status` flipped to `ready_invoice`. * - `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. * - back-link trimmed.
* - auto-`status: "cancelled"` if the back-link becomes empty. * - 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 markInStockReady = async (supplierId: string) => {
const supplier = suppliers.find((s) => s.id === supplierId); const supplier = suppliers.find((s) => s.id === supplierId);
@ -1440,6 +1455,36 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
setMarkingInStockSupplier(supplierId); setMarkingInStockSupplier(supplierId);
const pb = getPocketBase(); const pb = getPocketBase();
try { 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<PurchaseOrder>({
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<string, Set<string>>(); const affectedPoIds = new Map<string, Set<string>>();
const cancelledPoNumbers: string[] = []; const cancelledPoNumbers: string[] = [];
let stockFailures = 0; let stockFailures = 0;
@ -1459,14 +1504,19 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
try { try {
// In-stock items are goods that already sit on our // In-stock items are goods that already sit on our
// shelves — the operator is telling us "we have these, // shelves — the operator is telling us "we have these,
// don't send a PO". The invoicing flow bills whatever's in // don't put a driver on the road". The invoicing flow
// `qty_collected`, so leaving qty_collected=0 would push a // bills whatever's in `qty_collected`, so leaving
// 0-line invoice to QuickBooks for every in-stock item. // qty_collected=0 would push a 0-line invoice to
// Stamp qty_collected=quantity here so the in-stock flip // QuickBooks for every in-stock item. Stamp
// is treated as "fully received from our own warehouse" // 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. // 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, { await pb.collection("order_items").update(it.id, {
purchase_order: "", purchase_order: po.id,
item_status: "ready_invoice", item_status: "ready_invoice",
qty_collected: Number(it.quantity ?? 0), 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); linkErr instanceof Error ? linkErr.message : String(linkErr);
stockErrors.push(`${it.description.slice(0, 30)}…: ${msg}`); stockErrors.push(`${it.description.slice(0, 30)}…: ${msg}`);
console.error( 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, 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()) { for (const [poId, unlinkedIds] of affectedPoIds.entries()) {
try { try {
const po = await pb.collection("purchase_orders").getOne(poId); const poRow = await pb.collection("purchase_orders").getOne(poId);
const currentLinks: string[] = Array.isArray(po.order_item) const currentLinks: string[] = Array.isArray(poRow.order_item)
? po.order_item.filter( ? poRow.order_item.filter(
(x): x is string => typeof x === "string", (x): x is string => typeof x === "string",
) )
: []; : [];
@ -1498,9 +1552,9 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
order_item: [], order_item: [],
status: "cancelled", status: "cancelled",
}); });
cancelledPoNumbers.push(po.po_number || poId); cancelledPoNumbers.push(poRow.po_number || poId);
console.log( 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 { } else {
await pb.collection("purchase_orders").update(poId, { 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) { if (stockFailures > 0) {
const summary = stockErrors[0] ?? ""; const summary = stockErrors[0] ?? "";
toast.warning( 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 }, { 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( toast.message(
`Auto-cancelled empty draft PO(s): ${cancelledPoNumbers.join(", ")}.`, `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(); await fetchAll();
} catch (err: unknown) { } catch (err: unknown) {
toast.error( 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 PO + email for the non-in-stock lines, and{" "}
<span className="font-medium">{`{supplier} in-stock`}</span>{" "} <span className="font-medium">{`{supplier} in-stock`}</span>{" "}
flips the in-stock lines straight to{" "} flips the in-stock lines straight to{" "}
<Badge variant="secondary" className="mx-0.5">ready for invoicing</Badge>. <Badge variant="secondary" className="mx-0.5">ready for invoicing</Badge>{" "}
and issues a stock-replenishment PO + PDF for the supplier
(no driver collection).
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{Object.entries(bySupplier).map(([supplierId, list]) => { {Object.entries(bySupplier).map(([supplierId, list]) => {
@ -2015,7 +2127,7 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
) : ( ) : (
<Package className="mr-1.5 h-4 w-4" /> <Package className="mr-1.5 h-4 w-4" />
)} )}
{name} in-stock ({inStockCount}) {name} stock PO ({inStockCount})
</Button> </Button>
)} )}
</div> </div>