Skip to main content
Version: v1.0.0(int)

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

  1. Load the SO. Call SalesOrders.getSoById.

  2. Validate the status transition (if any). Compare against the manual map. Terminal statuses (Paid, Rejected, Completed) accept no further transitions.

  3. Stamp the per-status timestamp on a transition. When orderStatus changes, set the matching *_at column to NOW(): placedAt for Received, enteredAt for Entered, shippedAt for Shipped, invoicedAt for Invoiced, paidAt for Paid, rejectedAt for Rejected. These dates drive the ledger eventDate on the next sync call.

  4. Apply the header patch. Call SalesOrders.updateSo with the patch and the timestamp updates from step 3.

  5. 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 matching FinishedGoodsInventory op:

    • 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 }) where unitCost is the FIFO weighted-average for the SKU at fulfillmentLocationId at the consume timestamp.
  6. 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 matching FinishedGoodsInventory reversal 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.
  • customerId is immutable. Cannot change after creation.
  • fulfillmentLocationId is editable only before ship. Editable while in Received or Entered. 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.
  • fulfillmentStatusId is 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.