Appearance
Part ordering
Each product line on a work order shows whether its part is OK, À commander (to order) or Commandée (ordered), and links to the purchase order that brings the part in. The technician reads the line and knows whether the part is on the shelf, missing, or on its way; a warranty claim follows the link back to the supplier's order.
The status is computed, never stored
App\Entities\PartOrderStatus has three cases: ok, to_order, ordered. No column holds it. WorkOrderLine::part_order_status (app/Models/WorkOrderLine.php) derives it on every read, in this order:
- Not a product line, or the line is canceled (its own
service_status, or its parent service's —WorkOrderLine::isCanceled()) →null, no status. - Linked to a purchase order line whose purchase order is not
canceled→orderedwhilequantity_received < quantity,okonce fully received. - The product's category does not track inventory →
null. - The line already consumed stock (it has
stock_mutations, which billing writes) orproducts.stock_quantity >= quantity→ok. - Otherwise →
to_order.
Deriving it means the status follows stock and purchase orders on its own. There is no listener to keep in sync and no status to go stale when an inventory count or another work order moves the stock.
The SQL twin
The work order list filters on "awaiting parts" (workOrders_awaiting_parts=1 on shop.workOrders.index) through WorkOrder::awaitingParts(), which is whereHas('lines', awaitingParts). WorkOrderLine::awaitingParts() is the same rule as the attribute, written in SQL: lines that would be to_order or ordered.
Change the two together. PartOrderStatusTest::assertStatus() checks every case through both, which is what catches a drift.
The scope deliberately narrows one thing the attribute does not: it only matches open work orders, excluding WorkOrderStatus::estimateStatuses() and closedStatuses(). An estimate short on stock still shows « À commander » on its line — a quote needs to know a part is missing — but it is not a work order waiting on a part.
The link to the purchase order
work_order_lines.purchase_order_line_id (nullable, nullOnDelete) points at the purchase order line that brings, or brought, the part in. It lives on the work order line because one delivery serves many jobs: ten filters received, used on five work orders.
It is written in three places:
| When | Where | Rule |
|---|---|---|
| The part is ordered from the line | AddProductToPurchaseOrder with workOrderLine: | Set on the line as the purchase order line is created. |
| A purchase order is received | ReceivePurchaseOrder::linkWaitingWorkOrderLines() | Links the oldest waiting lines of the same shop, as long as the received quantity covers each line whole. A line that does not fit is skipped. |
| The work order is billed | SaveInventory::linkSupplyingPurchaseOrderLine() | Follows the consumed stock layer's last_stock_id to the receipt's StockMutation; only when the line has no link yet. |
The receipt hook runs before the stock increase, while the waiting lines are still short of stock — after it, awaitingParts would no longer match them. It only considers lines with no non-canceled link, so a line ordered on another purchase order keeps its own, and a line whose order was canceled can be picked up by the next delivery.
WorkOrderLineData exposes purchase_order_id and purchase_order_number from the link, and hides both when that purchase order is canceled. The line renders the number as a button to the purchase order, including after billing.
Ordering a part
POST /{shop}/work-orders/{workOrder}/{workOrderLine}/purchase-order (shop.workOrders.product.purchase-order) runs OrderWorkOrderLinePart. The route sits behind module:inventory, as do the whole shop.purchaseOrders.* and shop.suppliers.* groups; a shop whose plan leaves inventory off gets a 403 (see Plans & modules).
- It reuses the supplier's latest
draftpurchase order in the work order's shop, or creates one, so parts ordered for several jobs on the same day land on one order. A sent (ordered) purchase order is never reused. - The quantity is
ceil(line quantity − max(0, stock_quantity)), at least 1. - It needs
updateon the work order andcreateonPurchaseOrder. A line that is not a product, or a product withoutsupplier_id, is rejected with asuppliervalidation error. - It redirects to the purchase order.
The shop comes from the work order, not the product: products.shop_id is nullable.
Gotchas
- Stock is not reserved. Step 4 compares the line against the product's whole on-hand stock. Two open work orders can both show OK for the last unit.
- The billing link is approximate. A stock layer merges every receipt at the same unit cost and keeps only the latest in
last_stock_id, so the link names the last purchase order that fed the layer. The weighted average method consumes no layer and records no link. - The receipt and billing writes are silent. The receipt links with one query-builder
updateand billing usessaveQuietly(): billing must not recalculate totals or fire automations on an invoice being issued. Neither broadcasts onwork_orders.{id}, so an open work order page shows the new status on its next load. - The parent comes from the chaperone.
isCanceled()readsparent.children()is declared with->chaperone('parent'), so child lines loaded through it already hold their service and the read costs no query. Without it, the automatic eager loading enabled inconfig/essentials.phpwould still resolveparentin one batched query — never an N+1, and never a lazy-loading violation, which is why nothing fails if the chaperone is removed.PartOrderStatusTest::the_show_page_hides_the_status_of_a_part_under_a_canceled_serviceis the only thing that notices.