Skip to content

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:

  1. Not a product line, or the line is canceled (its own service_status, or its parent service's — WorkOrderLine::isCanceled()) → null, no status.
  2. Linked to a purchase order line whose purchase order is not canceled → ordered while quantity_received < quantity, ok once fully received.
  3. The product's category does not track inventory → null.
  4. The line already consumed stock (it has stock_mutations, which billing writes) or products.stock_quantity >= quantity → ok.
  5. 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.

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:

WhenWhereRule
The part is ordered from the lineAddProductToPurchaseOrder with workOrderLine:Set on the line as the purchase order line is created.
A purchase order is receivedReceivePurchaseOrder::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 billedSaveInventory::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 draft purchase 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 update on the work order and create on PurchaseOrder. A line that is not a product, or a product without supplier_id, is rejected with a supplier validation 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 update and billing uses saveQuietly(): billing must not recalculate totals or fire automations on an invoice being issued. Neither broadcasts on work_orders.{id}, so an open work order page shows the new status on its next load.
  • The parent comes from the chaperone. isCanceled() reads parent. 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 in config/essentials.php would still resolve parent in 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_service is the only thing that notices.