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

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

  1. Generate the next SO number. Use the per-tenant counter for this org to produce SO-001, SO-002, and so on.

  2. 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.

  3. Create the SO header. Call SalesOrders.createSo with the full header: customerId, orderChannelId, brokerId, brokerFeePercent, fulfillmentLocationId, MABD (must-arrive-by date), the cost fields (inventoryCost, fulfillmentCosts, freightCosts, promoCosts, otherCosts, brokerFees, computed orderValue), the initial orderStatusId and fulfillmentStatusId, optional qboId and notes. Stamp placedAt = NOW() on creation; later status transitions stamp the matching *_at column.

  4. Add each line item. For every input line, verify the SKU exists, then call SalesOrders.addItem with quantity and unitPrice.

  5. 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 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.

    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 at Received therefore writes one DEMAND row per line and nothing else.

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 each FinishedGoodsInventory.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. orderValue is rolled up by L3 from the line items plus the surcharges. W027's cost cascade does not update SO cost fields; SO inventoryCost is captured at SO creation and stays.
  • Status timestamps are part of the row. placedAt, enteredAt, shippedAt, invoicedAt, paidAt, rejectedAt, deliveredAt are stored on the SO and stamped by the matching transition (W147). They drive the eventDate on 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.