fix(stock-purchase-send): stop auto-flipping stock_purchase_items on success

Same operator-flow fix we made for customer POs in 79b9309: sending a
stock PO no longer auto-flips the linked stock_purchase_items from
`collection_pool` to `assigned_to_driver`. Items now stay in
`collection_pool` after the supplier email goes out; the operator
marks them ready-to-collect from the stock-order page only after the
supplier confirms, and that's the action that promotes them to
`assigned_to_driver` (so the driver-app sees them as ready).

Without this, the stock-order "PO Sent" state was invisible — the
items jumped straight to driver-ready the moment the email left,
before any supplier confirmation. Now the PO Sent → Ready to Collect
→ Assign Driver flow is observable end-to-end.

3 new tests, full suite 1210/1210 passing.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 16:04:21 +02:00
parent a8231fec07
commit e75f0d6157
2 changed files with 109 additions and 27 deletions

View file

@ -179,6 +179,7 @@ import {
getStockPurchaseItems,
receiveStockPurchase,
markStockPurchaseSent,
recordStockPurchaseSendOutcome,
} from "./stock-purchases-service";
beforeEach(() => {
@ -812,3 +813,92 @@ describe("listStockPurchases — total value", () => {
expect(y.received_value).toBe(100);
});
});
// ============================================================================
// recordStockPurchaseSendOutcome
// ============================================================================
describe("recordStockPurchaseSendOutcome", () => {
it("does NOT auto-flip stock_purchase_items.status on success (operator must mark them ready to collect manually)", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "sending",
email_payload: { sentAt: "", sentMessageId: "" },
});
const it1 = await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
status: "collection_pool",
});
const it2 = await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
status: "collection_pool",
});
const result = await recordStockPurchaseSendOutcome({
purchaseId: p.id,
outcome: "success",
messageId: "<abc@wfsales.co.za>",
});
// The stock_purchase itself flips to "sent" + stamps the message ID.
expect(result.status).toBe("sent");
expect(result.email_message_id).toBe("<abc@wfsales.co.za>");
// The items MUST stay in collection_pool — the operator marks them
// ready-to-collect from the stock-order page only after the supplier
// has confirmed. Same pattern as customer POs.
const itemsAfter = await pbMock
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${p.id}"` });
expect(itemsAfter.find((i) => i.id === it1.id)?.status).toBe("collection_pool");
expect(itemsAfter.find((i) => i.id === it2.id)?.status).toBe("collection_pool");
});
it("flips stock_purchase to sent + stamps email_message_id on success", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "sending",
email_payload: { sentAt: "", sentMessageId: "" },
});
const result = await recordStockPurchaseSendOutcome({
purchaseId: p.id,
outcome: "success",
messageId: "<abc@wfsales.co.za>",
});
expect(result.status).toBe("sent");
expect(result.email_message_id).toBe("<abc@wfsales.co.za>");
expect(result.email_error).toBe("");
expect((result.email_payload as Record<string, unknown>).sentAt).toBeTruthy();
expect((result.email_payload as Record<string, unknown>).sentMessageId).toBe(
"<abc@wfsales.co.za>",
);
});
it("flips stock_purchase to mail_failed + stamps errorMessage on failure (does not touch items)", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "sending",
});
const it1 = await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
status: "collection_pool",
});
const result = await recordStockPurchaseSendOutcome({
purchaseId: p.id,
outcome: "failure",
errorMessage: "SMTP 550 mailbox unavailable",
});
expect(result.status).toBe("mail_failed");
expect(result.email_error).toBe("[smtp] SMTP 550 mailbox unavailable");
// Items are NOT touched on failure (and were never touched to begin with).
const itemsAfter = await pbMock
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${p.id}"` });
expect(itemsAfter.find((i) => i.id === it1.id)?.status).toBe("collection_pool");
});
});

View file

@ -1036,11 +1036,13 @@ async function markPreSendFailure(
* after the email goes out (success or failure).
*
* `outcome = "success"` flips status to `sent` + stamps delivery
* fields + flips linked `stock_purchase_items.status` from
* `collection_pool` to `assigned_to_driver` (so the driver-app
* can pick them up). Note: stock lines use `assigned_to_driver`
* instead of `assigned` because their per-line state machine has
* different values than the customer side.
* fields. It deliberately does NOT touch linked
* `stock_purchase_items.status` — the operator marks items
* ready-to-collect manually from the stock-order page after the
* supplier confirms, which then moves them to `assigned_to_driver`.
* Auto-flipping on send would hide the item before the supplier has
* even confirmed (mirrors the customer-PO behaviour adopted in
* commit 79b9309).
*
* `outcome = "failure"` flips to `mail_failed` + stamps error.
*/
@ -1081,29 +1083,19 @@ export async function recordStockPurchaseSendOutcome(
.collection(STOCK_PURCHASES_COLL)
.update(input.purchaseId, patch)) as StockPurchase;
// Flip linked stock_purchase_items from `collection_pool` to
// `assigned_to_driver`. We don't touch `drv_id` — that's set
// later by the assign-driver action on /collection-pool. Status
// flip here just signals "PO is in flight; ready for the operator
// to assign a driver when the supplier confirms".
// Stock_purchase_items stay in `collection_pool` after the send.
// The operator marks them ready-to-collect from the stock-order
// page after the supplier confirms, which is what moves them to
// `assigned_to_driver` (so the driver-app can pick them up).
// We don't touch `drv_id` here — that's set later by the
// assign-driver action on /collection-pool. The status names
// align with the v4 stock-order driver flow:
// collection_pool → assigned_to_driver → collected → booked.
//
// v1 note: this mirrors the old `markStockPurchaseSent` behaviour
// (which also flipped all lines to collection_pool at the time
// of send). The status names align with the new v4 stock-order
// driver flow: collection_pool → assigned_to_driver → collected
// → booked.
const linked = await pb
.collection(STOCK_PURCHASE_ITEMS_COLL)
.getFullList({
filter: `purchase = "${input.purchaseId}" && status = "collection_pool"`,
fields: "id,status",
});
for (const it of linked) {
await pb.collection(STOCK_PURCHASE_ITEMS_COLL).update(it.id, {
status: "assigned_to_driver",
});
}
// v1 note: this used to auto-flip on send (mirroring the old
// `markStockPurchaseSent` behaviour), but that hid items before
// the supplier had even confirmed. Now consistent with customer
// POs (see commit 79b9309).
return updated;
}