W144: Create sales order
Opens a new sales order and records demand against finished-goods inventory. Triggered from the Sales Orders page, "New SO". On creation the SO already lands at status Received (or whatever initial status the caller picks), so the FG ledger DEMAND entries are written immediately and the SO is visible in inventory planning right away.
Steps
-
Generate the next SO number. Use the per-tenant counter for this org to produce
SO-001,SO-002, and so on. -
Validate referenced rows. Confirm the customer, fulfillment location, channel, order status type, fulfillment status type, and (if supplied) broker exist and are active in this org.
-
Create the SO header. Call
SalesOrders.createSowith the full header:customerId,orderChannelId,brokerId,brokerFeePercent,fulfillmentLocationId,MABD(must-arrive-by date), the cost fields (inventoryCost,fulfillmentCosts,freightCosts,promoCosts,otherCosts,brokerFees, computedorderValue), the initialorderStatusIdandfulfillmentStatusId, optionalqboIdandnotes. StampplacedAt = NOW()on creation; later status transitions stamp the matching*_atcolumn. -
Add each line item. For every input line, verify the SKU exists, then call
SalesOrders.addItemwithquantityandunitPrice. -
Reconcile the FG ledger to the initial status. Run the status → ledger reconciliation projector. It derives the net-active event set from
orderStatus(Received → DEMAND; Entered → DEMAND + ALLOCATE; Shipped or later → DEMAND + ALLOCATE + CONSUME) and, for each line item and each implied event type, 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.
The projector is idempotent: before writing it checks no ledger entry already exists with the same
(source_type='sales_order', source_id=soItemId, event_type, sku_id)tuple. A new SO atReceivedtherefore writes one DEMAND row per line and nothing else. - DEMAND →
Returns
The new SO with hydrated items.
Business rules
- Idempotent ledger writes. Step 5's reconciliation projector dedups on
(source_type, source_id, event_type, sku_id)before eachFinishedGoodsInventory.record*call, so re-running a backfill or a later update (W147) is safe — create and update share the same projector. - Cost fields are user-supplied.
orderValueis rolled up by L3 from the line items plus the surcharges. W027's cost cascade does not update SO cost fields; SOinventoryCostis captured at SO creation and stays. - Status timestamps are part of the row.
placedAt,enteredAt,shippedAt,invoicedAt,paidAt,rejectedAt,deliveredAtare stored on the SO and stamped by the matching transition (W147). They drive theeventDateon each ledger entry. - MABD is required. Sales ops uses it for filtering and prioritization; the schema enforces NOT NULL.
Errors
NotFoundError. The customer, location, channel, status type, broker, or a SKU was not found.ValidationError. A referenced status type is not configured for this org, or MABD is missing.