Skip to content

Phase 4 PO Flow Follow-up — Findings

Status: closed (2026-05-17) — all six post-merge follow-ups shipped

Tracks the implementation of the Phase 4 PO Flow Plan (design locked 2026-05-15). All seven design PRs merged 2026-05-15 → 16; four staging-deploy fixes (#101, #107, #108, #110) ironed out the Medusa-runtime gotchas; full smoke green on CT 105 covering D1–D8. Six post-merge follow-ups closed out 2026-05-17 via #127 + #129 (see Follow-up status below). PR #130 added import-picker E2E coverage, aligned dashboard React to react-dom, and documented the Cloudflare Access static-asset bypass required for client hydration on staging.

What shipped

PR Title Surface GitHub
PR-1 po_date + expected_delivery_date + overdue chip (D6) Server migration + DTOs + dashboard form + list-view chip #94
PR-2 Explicit quantity_received counter on import_batch_item Server migration + model field #100 (PR #95 was cascade-closed when PR-1 merged with --delete-branch — same workaround as the Phase 4 wire-up cascade)
PR-3 Variant picker + stock preview + landed-cost preview service Combobox + 2 server endpoints + service #96
PR-4 import_batch_receipt table + receiveBatchItem workflow + status enum (D2/D3/D4/D5/D7/D8) Migration + model + workflow + 2 endpoints + DTOs #97
PR-5 Dashboard partial-receive panel + receipt history + D7 overage toggle Per-line UI + history table + server actions #98
PR-6 Receive workflow integration tests 3 new test cases on admin-dashboard-imports.spec.ts #99
PR-7 This findings doc Docs hub this PR

Stack

All seven PRs are linearly stacked. Merge order must be 1 → 7. After PR-1 merges, the rest will auto-retarget; downstream branches may need a git rebase --onto main <old-base-sha> to drop the squashed commit ([reference_medusa_subscriber_wiring]'s sibling gotcha applies — see Phase 4 Wire-up Findings closing notes).

Staging-deploy bugs (4)

Deploying the merged stack to CT 105 surfaced four bugs the local PR-by-PR work hadn't caught. All shipped as one-line follow-up PRs:

PR Title Symptom Root cause
#101 Fix partially_received enum migration ALTER TYPE … ADD VALUE failed with type does not exist Medusa DML enums render as text columns + CHECK constraint, not Postgres enum types. Fix: drop + recreate the check constraint with the new value included.
#107 Rename receive step to avoid . in id Every receive POST returned 500 with TransactionOrchestrator.canContinue reading .id of undefined Step name "receive-batch-item.run" contains a dot. The orchestrator parses parent step IDs by splitting on ., so the dot made the parent lookup fail. Earlier multi-step iterations used dotted names like "receive-batch-item.validate" and crashed identically — the bug was the dot, not the composition. One-character fix: .-.
#108 Add raw_effective_cost_at_receipt_sgd column Receipt insert failed: column raw_effective_cost_at_receipt_sgd does not exist Medusa model.bigNumber() requires a paired raw_<field> JSONB column for the high-precision representation. The PR-4 migration only created the numeric column. Pattern matches batch_allocation.raw_cost_per_unit_at_allocation and batch_adjustment.raw_cost_delta_per_unit.
#110 Set applied_at on auto-overship adjustment (closes #109) D7 force=true returned 422 with Value for BatchAdjustment.applied_at is required The auto-create payload for the overship batch_adjustment row didn't set applied_at. The manual adjustment routes already do — this auto-path now matches.

Smoke results (2026-05-16, CT 105)

End-to-end against the deployed Medusa, with all four staging fixes merged:

Check Result
D1 — po_date + expected_delivery_date round-trip
D2/D3/D4 — partial → arrived auto-transition partially_receivedarrived
D5 — receipt rows with cost snapshot ✅ 3 rows in history
D7 — over-receive without force rejected with 422 + helpful message
D7 — force=true creates overage adjustment and records receipt
D8 — consolidation reject ✅ (covered by integration test in #99)
Receipt history endpoint returns chronological list

Deviations from the locked design

D-1 — quantity_received was added fresh, not "dropped + recreated"

The locked plan (D2) described the column as a generated column that PR-2 would drop and recreate as plain. In reality the wire-up never landed quantity_received at all — it landed received_into_inventory_at (single timestamp) instead. PR-2 adds the column fresh; backfill comes from the timestamp (non-null → quantity_ordered, null → 0). The data-model §4.2 description was reconciled to the explicit-counter implementation in the same closeout pass.

D-2 — Status enum migration is single-step

The plan flagged Risk R-B (ALTER TYPE in a single transaction is tricky). Medusa's migration runner commits each addSql() sequentially, so the single migration in PR-4 works. No two-transaction split needed.

D-3 — OVERDUE_PENDING_STATUSES narrowed in PR-1, widened in PR-4

PR-1 ships the D6 overdue chip with status set {ordered, paid, in_transit}partially_received doesn't exist until PR-4. PR-4 widens the set to include partially_received. Net effect after the stack merges: chip behaves per the locked design.

The receive workflow's step 4 (incrementInventoryStep) returns null inventory_level_id if the line has no linked product_variant or the variant has no inventory_item. The receipt + qty counter + status transition still fire. This deviates from a strict reading of the design (which assumed every line has a linked variant) but matches the wire-up's existing behavior — line items without variant links are common during the migration period.

D-5 — Receipt history endpoint is per-item, not per-batch

The plan didn't specify a receipts-listing endpoint. PR-4 added GET /admin/dashboard/imports/:id/items/:itemId/receipts (per-item, for the dashboard history table). If a per-batch endpoint is needed later, it's a one-line server-side addition.

Smoke (proven via curl, pending in-browser pass)

The smoke above is the curl/API path. The dashboard pass (PR-1, PR-3, PR-5 UI surfaces) is the next operator-facing milestone:

  1. UI smoke (PR-1) — open /imports/new, today's date pre-fills PO date; set Expected delivery = 2026-05-01 (past); save; list view shows Overdue: N days chip
  2. UI smoke (PR-3) — open /imports/new, type a known SKU substring, picker shows match with stocked qty; pick variant; set qty + invoice; landed-cost preview updates as you type
  3. UI smoke (PR-5) — open a PO in in_transit; receive panel renders per-line; submit qty=3 → status → Partially received, history table shows the receipt; submit qty=remaining → status → Arrived
  4. D7 UI smoke — submit qty > expected → overage toggle appears; tick + submit → succeeds + batch_adjustment(reason=quantity_correction) recorded

Follow-up status

All six post-merge follow-ups closed out 2026-05-17 by #127 (bundled items 1, 2, 5, 6) and #129 (item 3 + the link-resolution bug it surfaced). Item 4 turned out to be already-handled docs drift; reconciled in this closeout.

# Follow-up Resolution
1 Adjustments in the detail responsePartialReceiveSection treated qtyDeltaByItem as {} because the detail endpoint didn't ship adjustments. Closed by #127. ImportBatchDetail now includes adjustments: BatchAdjustmentItem[]; the dashboard's PO page builds qtyDeltaByItem from it (running counter reflects shortfall / overship correctly).
2 defineLink(import_batch_receipt ↔ inventory_level) — promote inventory_level_id to a Module Link. Re-scoped + closed by #127. A true defineLink isn't buildable — Medusa's inventory module joinerConfig only exposes InventoryItem and ReservationItem as linkables, not InventoryLevel (@medusajs/inventory/dist/joiner-config.js). The receipt row's existing text column is now actually populated by the workflow's step-4b backfill once the inventory increment succeeds.
3 Variant-link + inventory-write integration fixture — tests exercised the null-variant branch only. Closed by #129. New "variant-linked receive" test seeds product + variant + inventory_item + stock_location and asserts inventory_level.stocked_quantity increments correctly across both create-new and update-existing branches. Surfaced + fixed issue #128 in the same PR (workflow's linkSvc.list returned empty for valid link rows — replaced with query.graph traversal). Smoke on CT 105 confirmed end-to-end: stocked_quantity went 100 → 103 → 104 across two receives.
4 data-model.md update for §4.2 quantity_received. Already done by an earlier pass — §4.2 line 152 reads "Explicit counter maintained by receiveBatchItem; starts at 0 and increments on physical receipt events." Stale follow-up; the deviation paragraph above has been reconciled.
5 Wire-up ReceiveSection component dead codereceive-section.tsx + receive-form.tsx no longer rendered. Closed by #127. Both files deleted; PartialReceiveSection / PartialReceiveForm are the live components.
6 Legacy /imports/:id/receive endpoint deprecation. Closed by #127. Endpoint stays mounted but now emits a [deprecated] warn log + Deprecation: true + Link: <…/items/:itemId/receive>; rel="successor-version" response headers. Removal slated once the warn log shows zero hits for two release cycles.

Post-closeout hardening

PR #130 closed the staging-only import-picker hydration regression follow-up:

  • apps/dashboard now pins react to 19.2.6 to match react-dom.
  • /imports/new has Playwright coverage for variant search, result selection, and the line-items dropdown clipping regression.
  • apps/dashboard/README.md documents the required Cloudflare Access Bypass application for /_next/static/*, /_next/image*, and /favicon.ico. Without that bypass, Access redirects module/script asset requests to exzen.cloudflareaccess.com, SSR HTML renders, and client-only widgets such as the variant picker never hydrate.

Risks ratified at code-complete

Risk Status
R-A: in-flight POs at migration time Mitigated — staging has known state (smoke-validated batch from wire-up)
R-B: enum migration split Single-step works in Medusa's migration runner; no split needed
R-C: overship cost recompute makes per-unit cheaper Confirmed correct-but-surprising; PR-6 test doesn't assert recompute math (worker-mode subscriber fires async; would need polling). Captured for staging smoke.