W147: Update sales order
Edits an SO's header fields and walks it through its lifecycle: Received → Entered → Shipped → Invoiced → Paid → Completed, plus terminal branches Rejected and In-Dispute. Triggered from Sales Order Detail. Each status change re-runs the status → ledger reconciliation projector against the FG ledger: DEMAND from Received onwards, ALLOCATE from Entered onwards, CONSUME from Shipped onwards, with REVERSE_* on Rejected. Writes are append-only; the projector dedups before each call so the workflow is safe to re-run.
Steps
-
Load the SO. Call
SalesOrders.getSoById. -
Validate the status transition (if any). Compare against the manual map. Terminal statuses (
Paid,Rejected,Completed) accept no further transitions. -
Stamp the per-status timestamp on a transition. When
orderStatuschanges, set the matching*_atcolumn toNOW():placedAtforReceived,enteredAtforEntered,shippedAtforShipped,invoicedAtforInvoiced,paidAtforPaid,rejectedAtforRejected. These dates drive the ledgereventDateon the next sync call. -
Apply the header patch. Call
SalesOrders.updateSowith the patch and the timestamp updates from step 3. -
Reconcile the FG ledger to the new status. Run the status → ledger reconciliation projector. It derives the net-active event set from the new
orderStatus(Received → DEMAND; Entered → DEMAND + ALLOCATE; Shipped or later → DEMAND + ALLOCATE + CONSUME). For each line item and each implied event type, it dedups on(source_type='sales_order', source_id=soItemId, event_type, sku_id)and calls the matchingFinishedGoodsInventoryop:- DEMAND →
FinishedGoodsInventory.recordDemand({ skuId, locationId: fulfillmentLocationId, quantity, ref: soNumber, eventDate: placedAt, sourceType: 'sales_order', sourceId: soItemId }) - ALLOCATE →
FinishedGoodsInventory.recordAllocation({ ..., eventDate: enteredAt ?? placedAt }) - CONSUME →
FinishedGoodsInventory.recordConsumption({ ..., eventDate: shippedAt ?? enteredAt ?? placedAt, unitCost })whereunitCostis the FIFO weighted-average for the SKU atfulfillmentLocationIdat the consume timestamp.
- DEMAND →
-
Reverse open entries on
Rejected. The projector's backward path: forward writes in step 5 stop on this transition, and for each open DEMAND / ALLOCATE entry that has not already been reversed it calls the matchingFinishedGoodsInventoryreversal op:FinishedGoodsInventory.recordReverseDemand({ originalEntryId, eventDate: NOW() })FinishedGoodsInventory.recordReverseAllocate({ originalEntryId, eventDate: NOW() })
CONSUME entries from a partial ship before cancellation are not reversed; the goods physically left the building. Reversal idempotency is the FG component's contract — it refuses to write a second reverse against the same
originalEntryId.
Returns
The updated SO.
Business rules
- Append-only ledger by design. Status regressions and cancellations don't delete history; they write reverse entries so the audit trail is complete.
customerIdis immutable. Cannot change after creation.fulfillmentLocationIdis editable only before ship. Editable while inReceivedorEntered. Existing DEMAND / ALLOCATE entries are reversed and re-issued at the new location.- CONSUME unit cost is FIFO at consume time. Once stamped it is not re-priced if upstream costs change later. The W027 cascade does not touch SO CONSUME entries.
fulfillmentStatusIdis currently a stored field. For v1.0.0 it is recomputed from the ledger CONSUME totals (see W146); the stored column persists as an admin override.
Errors
NotFoundError. The SO was not found.InvalidTransitionError. The requested status change is not allowed (out of map, or from a terminal state).ValidationError. A referenced row (status type, location) is invalid for this org.