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
|
* 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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue