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
* `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<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 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{" "}
<span className="font-medium">{`{supplier} in-stock`}</span>{" "}
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>
<div className="flex flex-wrap gap-2">
{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" />
)}
{name} in-stock ({inStockCount})
{name} stock PO ({inStockCount})
</Button>
)}
</div>