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:
parent
d8e8714720
commit
b43ad41ab4
1 changed files with 142 additions and 30 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue