Wiki · canonical integration · R593 · the system of three

How SO + PO + WO work together

Three NetSuite transaction lifecycles — customer side, vendor side, production side — and the Finance handoff that ties them together. Start here if you want to see how the whole operation fits as one system instead of three separate processes.

Master · integration view SO + PO + WO + Finance
What this is

The system of three — Mike's framing

Three NetSuite transaction lifecycles run the business. Together they are the operating system; separately they are easy to misunderstand.

The three flows interconnect through two cross-lane bridges and one universal convergence. SO Path 2 (assembly) creates a Work Order from the SO — WO.createdfrom = SO.id preserves the link. SO Path 3 (dropship) creates a Purchase Order from the SO — PO.createdfrom = SO.id preserves the link. WO Path 1 (from-SO) is the consuming side of SO Path 2; PO Path 1 (SO-connected) is the consuming side of SO Path 3. All three converge at the Finance handoff post-fulfillment / post-receipt. Finance reconciles SO + PO + WO at monthly close.

Diagram: sales-purchase-work-order-integration.html. Lane masters: SO master · WO lifecycle. Cross-lane bridges live in SO Path 2 and SO Path 3.

When to use it

Decision logic — when does an order spawn each combination?

A single Sales Order routinely spans multiple lifecycles because lines often differ in item type. The decision happens at SO entry — today operator-driven, tomorrow auto-classified.

If the SO contains…It spawns…Bridge field
Only inventory items (already on hand)SO only. No WO, no PO. Reserve → pick → pack → ship.none — SO Path 1 only
One or more assembly items (need to build)SO + WO chain. WO created from SO. Build then fulfill.WO.createdfrom = SO.id
One or more dropship items (vendor ships direct)SO + PO chain. PO created from SO. Vendor ships, Item Receipt posts, SO fulfills.PO.createdfrom = SO.id
Mixed lines (most common at GFS)SO + WO + PO. Single SO fans out into BOTH a WO and a PO, plus inventory pulls for any in-stock lines.both createdfrom bridges
Branch decision lives at SO entry

After SO entry, each line is classified by item type. Inventory items stay in Lane 1 (SO Path 1). Assembly items spawn into Lane 3 (WO). Dropship / special-order items spawn into Lane 2 (PO). One SO can do all three.

★ The universal handoff

All three lifecycles fire a Finance alert at their post-fulfillment / post-receipt moment. SO fires on Item Fulfillment. PO fires on Item Receipt. WO fires on close (with cost variance). Finance reconciles all three at monthly close.

Worked example

Driscoll orders 50 cases of Right Start RS-12

Scenario · the all-3-lifecycles case

Thursday 14:00. Driscoll (NS customer #2147, 36.4% of revenue) emails an order to orders@globalfoodsolutions.co: 50 cases of Right Start RS-12 plus 1 line for a specialty vendor item. Customer PO# is 72622.

Lane 1 SO entry (14:08): Team keys the SO into NetSuite, captures otherrefnum = "72622", NS-assigns tranid = "1217". Lines classified: 30 cases pull from existing RS-12 inventory (Path 1), 20 cases need to be built (Path 2 spawns WO), 1 specialty line drop-ships from vendor (Path 3 spawns PO).

Lane 3 WO spawn: 20-case Work Order created with WO.createdfrom = SO.id. WO entered, released, components reserved against the BOM. Production crew completes the Assembly Build Friday morning — HITL approval at /assembly-build.html, Mike taps Approve. 20 cases of RS-12 land back in inventory.

Lane 2 PO spawn: Purchase Order created with PO.createdfrom = SO.id, memo = "72622". PO transmitted to vendor Friday morning. Vendor ships direct to Driscoll's location Friday afternoon. Item Receipt posted against the PO — PO line closed.

Lane 1 Item Fulfillment converges (Friday 15:30): All 50 cases plus the specialty line ship to Driscoll. Single Item Fulfillment closes. otherrefnum = "72622" threads through. ★ Finance alert fires (SO side).

Lane 4 Finance reconciles: Finance reviews fulfillment, posts CustInvc for $18,420. Net 30 terms. Meanwhile, PO side: ★ Finance alert fires (PO side) on the Item Receipt, queuing 3-way match for the vendor bill (entered the following week, paid per vendor terms). WO side: ★ Finance roll-up fires on the WO close, booking the build variance into COGS.

Three lifecycles, one customer order, one Finance reconcile. June 22 (Net 30): Driscoll pays, events.order.closed fires. The customer PO# 72622 traces across SO · PO · IF · IR · Invoice · Vendor Bill via the universal thread.

Step-by-step what happens

Four scenarios — from simplest to most complex

Same SO entry, four different fan-out shapes. The path classification at step 04 decides which lifecycles fire.

Scenario 1 — Simple inventory order (SO only)

  1. 01

    SO entered with all-inventory lines

    All lines fall under inventory_item. No assembly, no dropship. Path 1 only.

  2. 02

    Lane 1 SO Path 1 runs

    Reserve qty → pick → pack → stage → Item Fulfillment closes. Inventory decrements.

  3. 03

    ★ Finance handoff fires once

    SO side only. Invoice posted, AR open, payment received, SO closed. PO and WO lanes never engaged.

Scenario 2 — Assembly order (SO + WO chain)

  1. 01

    SO entered with assembly lines

    One or more lines are assembly_item. SO Path 2 dispatches.

  2. 02

    Lane 3 WO spawned from SO

    WO.createdfrom = SO.id. WO entered → released → components reserved against BOM.

  3. 03

    Assembly Build completes

    HITL at /assembly-build.html. BOM consumed, FG credited back to inventory. WO closes.

  4. 04

    Lane 1 SO fulfills from new FG

    Now that the assembly is on hand, the SO ships. Item Fulfillment closes.

  5. 05

    ★ Finance handoff fires twice

    WO close fires the WO-side roll-up (COGS + variance). SO fulfillment fires the SO-side alert (invoice posting).

Scenario 3 — Dropship order (SO + PO chain)

  1. 01

    SO entered with dropship lines

    One or more lines are dropship_item or special-order. SO Path 3 dispatches.

  2. 02

    Lane 2 PO spawned from SO

    PO.createdfrom = SO.id. PO.memo = customer PO#. PO transmitted to vendor.

  3. 03

    Vendor ships · Item Receipt posted

    Vendor ships direct to customer (or to GFS crossdock). IR.createdfrom = PO.id. PO line closes.

  4. 04

    Lane 1 SO fulfills

    Item Fulfillment closes for the dropship line.

  5. 05

    ★ Finance handoff fires twice

    PO side: Item Receipt queues 3-way match for vendor bill entry. SO side: fulfillment fires invoice posting. Both alerts are by design — not duplicates.

  6. 06

    Vendor bill cycle runs in parallel

    Finance enters VendBill against PO. Paid per vendor terms (separate cycle from customer-side invoice).

Scenario 4 — Mixed order (SO + WO + PO, most common)

  1. 01

    SO entered with multi-line, multi-type

    SO has lines that fall into ALL three buckets — some inventory, some assembly, some dropship. This is the common case at GFS.

  2. 02

    The SO fans out into BOTH a WO AND a PO from the same SO

    Path 2 spawns Lane 3 WO with WO.createdfrom = SO.id. Path 3 spawns Lane 2 PO with PO.createdfrom = SO.id. Path 1 lines pull from existing inventory in parallel. All three threads run concurrently.

  3. 03

    Production + vendor + inventory all converge

    WO closes → FG in inventory. PO ships → Item Receipt posts. Inventory lines stay reserved. Once all three threads are ready, Lane 1 Item Fulfillment closes for the whole SO.

  4. 04

    ★ Finance handoff fires THREE TIMES

    SO-side alert (fulfillment), PO-side alert (Item Receipt), WO-side alert (close + variance). Finance reconciles all three at monthly close. The customer PO# threads through every record.

Outcomes

What's different after the cycle

SO
Closed
order.closed event
PO
Closed
if Path 3 engaged
WO
Closed
if Path 2 engaged
Finance
Reconciled
SO + PO + WO
Failure modes

What can go wrong — cross-lane edition

WO completed but SO not fulfilled (WO ↔ SO link broken)

Production finished the assembly, FG credited, but the SO still shows "Pending Fulfillment". Most likely WO.createdfrom got dropped or D1 mirror is stale. See runbook scenario below.

PO received but Item Receipt missing (vendor-side disconnect)

Vendor confirmed shipment, customer received goods, but no Item Receipt posted against the PO. Customer-side SO is blocked from fulfilling. Manual reconcile required.

Customer PO# missing on a downstream record

SO carries otherrefnum = "72622" but the PO created from it has blank memo, or the Vendor Bill is missing the thread. Trace via createdfrom chain to find the broken link.

Finance alert fires twice or three times for one SO

Expected for mixed / Path 3 orders. SO fulfillment alert + PO Item Receipt alert (+ WO close alert if Path 2). Design intent, not a bug. Finance UI should visually de-duplicate per customer/SO.

WO customer PO# field TBD — lifecycle linkage incomplete

SO carries otherrefnum; PO carries memo; WO has no canonical place yet. Expected pattern is bodyFields.memo, but Mike's review is pending. Until then, the WO step in the universal thread is implicit (via createdfrom) not explicit.

Related

Adjacent docs + diagrams

For developers

Code paths + invariants

ConcernWhere
SO master worker routesrc/index.ts — /api/so/create + /api/salesorder/:id
Inbound email pipelinesrc/email.ts
SO → WO bridgeworkorder.createdfrom = transactions.id (SO)
SO → PO bridgepurchaseorder.createdfrom = transactions.id (SO)
SO → IF inherititemfulfillment.createdfrom = so.id · inherits otherrefnum
PO → IR inherititemreceipt.createdfrom = po.id · inherits memo
PO → Bill inheritvendorbill.createdfrom = po.id · inherits memo / tranid
Convergence (SO side)itemfulfillment.status = 'Shipped' fires SO finance alert
Convergence (PO side)itemreceipt.posted fires PO finance alert
Convergence (WO side)events.wo.closed + events.assembly.built fire WO finance roll-up
Sync tierhot (2 min) for SO, WO, PO, fulfillment, receipt, invoice, bill, payment
STUB — WO PO# fieldTBD — Mike review pending · expected bodyFields.memo
STUB — Finance NS automationplanned R594 (workflow build to deliver alerts into Finance review queue)
STUB — auto-classificationoperator decides today; no /api/so/create classifier wired
// Cross-lane fan-out at SO entry function fanOutSO(so) { const wo_lines = so.lines.filter(l => items[l.item_code].itemtype === 'assembly'); const po_lines = so.lines.filter(l => items[l.item_code].dropship || items[l.item_code].special_order); const inv_lines = so.lines.filter(l => !wo_lines.includes(l) && !po_lines.includes(l)); if (wo_lines.length) createWO({ createdfrom: so.id, lines: wo_lines }); if (po_lines.length) createPO({ createdfrom: so.id, memo: so.otherrefnum, lines: po_lines }); if (inv_lines.length) reserveInventory({ so_id: so.id, lines: inv_lines }); } // ★ Three Finance handoff triggers if (itemfulfillment.status === 'Shipped') fireFinanceAlert('so', { so_id }); if (itemreceipt.posted) fireFinanceAlert('po', { po_id }); if (event === 'wo.closed') fireFinanceAlert('wo', { wo_id, variance });
Changelog

Dated trail · spot stale claims

Dated trail of when this doc was last touched, what changed, and what to look at if it feels stale.

DateRoundChangeTouched by
2026-05-26R593First shipped — canonical integration view tying SO + PO + WO into one operating system. 4-lane swimlane diagram with cross-lane bridges + Finance convergence. 12-section wiki per R586 structure. Driscoll RS-12 mixed-order scenario walks all 3 lifecycles firing in coordination.Mike + Claude
If today is more than 60 days past the latest changelog row, treat live system behavior as the source of truth. The doc may have drifted — verify against the SO master + WO lifecycle docs before acting on these claims.
Schema · data contract

The machine-readable spec

Canonical fields, table names, cross-record linkage. What code should match, what tests should assert.

Cross-record linkage (the bridge table)

Cross-record linkageFieldCarries
SO → WOWO.createdfrom = SO.idpreserves the SO ↔ WO link
SO → POPO.createdfrom = SO.idpreserves the SO ↔ PO link
SO → Item FulfillmentIF.createdfrom = SO.idinherits otherrefnum
PO → Item ReceiptIR.createdfrom = PO.idinherits memo
PO → Vendor BillBill.createdfrom = PO.idinherits memo, tranid
Customer PO# thread (universal)otherrefnum (SO, Inv) ↔ memo (PO, Bill) ↔ memo (WO — TBD)universal trace thread "72622"

D1 tables involved (across the 3 lifecycles)

TableLanePurpose
transactions (SalesOrd / PurchOrd / WorkOrd)allall three lifecycles live in one D1 table, differentiated by type
so_lines / transaction_linesLane 1SO line detail; parent_so_id tracking
work_ordersLane 3WO records with createdfrom bridge to SO
assembly_buildsLane 3build completion records
purchase_ordersLane 2PO records with createdfrom bridge to SO
item_fulfillmentsLane 1 convergeSO-side close point; createdfrom = so.id
item_receiptsLane 2 convergePO-side close point; createdfrom = po.id
vendor_billsLane 4 POAP open; createdfrom = po.id
customer_invoicesLane 4 SOCustInvc; carries otherrefnum
customer_payments / vendor_paymentsLane 4cash cycle records
v_customer_ar_agingLane 4dunning trigger source
inventory_balanceallstate shared across all 3 lanes — the actual ledger of stock
eventsallso.*, po.*, wo.*, finance.*

Events fired across the 3 lifecycles

event_typeLaneWhen
so.createdLane 1SO entered + mirrored
workorder.createdLane 3Path 2: WO spawned from SO
purchaseorder.createdLane 2Path 3: PO spawned from SO (or stock replen)
itemfulfillment.completedLane 1SO ships — fires SO finance alert
itemreceipt.postedLane 2Vendor ships — fires PO finance alert
assembly.builtLane 3Build complete (with variance) — fires WO finance roll-up
so.finance_alert_firedLane 4SO → Finance handoff
po.finance_alert_firedLane 4PO → Finance handoff
wo.closedLane 3WO complete — fires WO finance roll-up
order.closedLane 1SO Closed after payment apply
po.closedLane 2PO Closed after vendor pay
Runbook · when it breaks

It broke at 2am — what now

Cross-lane incidents. Different from a single-lane failure — here the linkage between lifecycles is what broke.

Scenario · WO completed but SO not fulfilled (WO ↔ SO link broken)

Production finished the assembly build, FG credited to inventory, but the SO still shows "Pending Fulfillment". The cross-lane bridge dropped somewhere.

  1. Verify the bridge: SELECT id, createdfrom, status FROM transactions WHERE type='WorkOrd' AND id=?createdfrom should equal the originating SO id.
  2. If createdfrom is NULL: the link is broken. Either rebuild via UPDATE transactions SET createdfrom=? WHERE id=? or open the WO in NS UI and re-link to the SO.
  3. Force SO resync: POST /api/sync/run?tier=hot&table=transactions&id=<so_id>
  4. Trigger fulfillment manually in NS: from the SO record, click "Fulfill" with the assembly now on hand.
  5. Verify the WO event fired: SELECT * FROM events WHERE event_type='wo.closed' AND payload LIKE '%<wo_id>%' — if missing, re-emit.

Scenario · PO received but Item Receipt missing (vendor-side disconnect)

Vendor confirmed shipment; customer received goods (Path 3 dropship) or warehouse confirmed receipt. But no Item Receipt posted against the PO, so Lane 1 SO is blocked from fulfilling.

  1. Check PO state: SELECT id, createdfrom, status, trandate FROM transactions WHERE type='PurchOrd' AND id=?
  2. Check for orphan vendor bill: SELECT * FROM vendor_bills WHERE po_id=? — if a bill exists without an IR, that's a sign of premature billing. Pause and reconcile.
  3. Post Item Receipt manually: in NS, open the PO → click "Receive" with the received qty.
  4. Force resync: POST /api/sync/run?tier=hot&table=transactions to pull the new IR into D1.
  5. Verify Lane 1 fulfills: after IR posts, the SO Item Fulfillment can complete. Trigger from the SO.

Scenario · Customer PO# missing on a downstream record

SO carries otherrefnum = "72622" but the PO created from it has blank memo, or the Vendor Bill is missing the thread. The universal trace is broken at some hop.

  1. Trace the chain: SELECT type, id, createdfrom, otherrefnum, memo FROM transactions WHERE id IN (so_id, po_id, if_id, ir_id, bill_id) — find the first row with blank PO# in the thread.
  2. Patch the field at NS: open the record in NS, fill in memo (PO/Bill) or otherrefnum (SO/Invoice).
  3. Resync D1: hot-tier sync the affected table.
  4. Verify the universal grep: grep "72622" across transactions.otherrefnum + purchase_orders.memo + customer_invoices.otherrefnum + vendor_bills.memo + vendor_bills.tranid should now light up every record.

Scenario · Finance alert fires twice (once for receipt, once for fulfillment)

Finance reports they got two notifications for the same customer order on a Path 3 dropship. Or three for a mixed order with Path 2 + Path 3.

  1. This is the design intent — not a bug. Path 3 fires both a PO-side alert (on IR) and an SO-side alert (on IF) for the same line. Mixed orders also fire a WO-side roll-up.
  2. De-duplicate visually: Finance review UI should group alerts by customer/SO id so the operator sees one row with three sub-events.
  3. If Finance is over-paged: rate-limit the alert delivery (batch by 5 min window) but keep the events distinct in the ledger.
  4. Don't suppress alerts at source: each lifecycle's alert is independent — PO bill cycle is a separate cash cycle from SO invoice cycle. Suppressing one breaks AP reconcile.

Logs to check

Kill switch · emergency stop

See kill-switches-state-machine.html for the full state machine + recovery procedure.

Escalation

Primary: Mike Levine (single-admin) · mikelevine@globalfoodsolutions.co. For prolonged cross-lane outage, notify warehouse lead (Lane 1 + 3) and accounting lead (Lane 2 + 4) so they can defer dependent work.

Backlog · open questions

What's not done · what's uncertain

Open punch list captured so it survives context switches.