At a Glance
Persona: Purchaser (Procurement Officer) · Module: purchase-order · Scenarios: ~34
Categories: Happy Path · Permission · Validation · Edge Case
E2E coverage: maps totests/402-po-purchaser-journey.spec.tsandtests/401-po.spec.tsin../carmen-inventory-frontend-e2e/
This page captures the test scenarios that the Purchaser persona (also titled Procurement Officer) directly drives in the purchase-order module. The Purchaser owns the PO from creation through transmission to the vendor — the span from po_status = draft to sent (03-user-flow-purchaser.md Section 2). Two creation paths converge on the same flow: a manual PO (po_type = manual) raised directly by procurement, and a PR-sourced PO (po_type = purchase_request) materialised via Convert-to-PO from the upstream purchase-request module through the bridge table tb_purchase_order_detail_tb_purchase_request_detail (01-data-model.md Section 2.5). Scenarios are grouped into the happy paths described in the user flow, the RBAC boundary enforced by PO_AUTH_001–PO_AUTH_003 and PO_AUTH_006 (Purchaser's edit / submit / transmit scope) versus PO_AUTH_004, PO_AUTH_005, PO_AUTH_007 (escalation to the Procurement Manager), the validation rules in 02-business-rules.md Section 2 (PO_VAL_001–PO_VAL_016) that the Purchaser can trigger from create / edit / submit time, and a small set of edge cases around decimal precision, currency, concurrency, and vendor-master state changes. Cross-persona handoffs that pivot off the Purchaser (X-PO-01, X-PO-02, X-PO-05, X-PO-10) live in the parent overview, not here.
| # | Scenario | Pre-condition | Steps | Expected |
|---|---|---|---|---|
| PUR-HP-01 | Create a minimal manual PO from a blank form | Purchaser purchase@blueledgers.com logged in with enum_stage_role = purchase; active vendor V1 in tb_vendor; active product P1 with a positive order_unit_conversion_factor; valid tb_currency and tb_credit_term rows exist |
1. Sidebar → Purchase Order → Create Purchase Order → Blank. 2. Fill header: pick vendor_id = V1, currency_id = THB, exchange_rate = 1.00000, credit_term_id, order_date = today, delivery_date = today + 7d, workflow_id. 3. Add one line: product_id = P1, order_qty = 10, order_unit_id, price = ฿125.50, tax_rate = 7%, discount_rate = 0%. 4. Click Save Draft. (Mirrors TC-PO-060201..TC-PO-060203.) |
tb_purchase_order row inserted with po_status = draft, doc_version = 0, a unique po_no generated by the numbering service (PO_VAL_001), total_qty = total_price = total_tax = total_amount = 0 until first save then recomputed per PO_CALC_001–PO_CALC_011; one tb_purchase_order_detail row with base_qty = 10 × conv_factor (3 dp); history appended with { po_status: 'draft', action: 'created' } per PO_POST_001; UI redirects to PO detail with header + items table loaded (TC-PO-060301). |
| PUR-HP-02 | Create a multi-line PO with FOC and discount lines | Active vendor + products; Purchaser on a draft PO from PUR-HP-01 | 1. Open the draft → Edit. 2. Add line L2: product_id = P2, order_qty = 4, price = ฿89.00, tax_rate = 7%. 3. Add line L3 (FOC): product_id = P3, order_qty = 1, price = 0, is_foc = true, tax_rate = 0%. 4. Add line L4 with a 5 % discount: product_id = P4, order_qty = 2, price = ฿200.00, discount_rate = 5%, tax_rate = 7%. 5. Save. |
All four lines persisted; per-line and header totals recompute per PO_CALC_001–PO_CALC_011; FOC line L3 contributes 0 to sub_total_price, discount_amount, tax_amount, and total_price but its order_qty and base_qty still roll up to total_qty per PO_CALC_007; total_amount = Σ Round(line.total_price, 2) shown at the header; rounding uses half-up via PO_CALC_012; PO stays draft. |
| PUR-HP-03 | Convert an approved PR to a PO via the From-PR wizard (bridge written) | One pr_status = approved PR for (vendor_id = V1, currency_id = THB) with two lines, both fully allocated, no prior bridge rows |
1. Sidebar → Purchase Order → Create Purchase Order → From PR. 2. Wizard step 1 — select the approved PR; wizard step 2 — review POs grouped by (vendor_id, currency_id) (one draft-PO group). 3. Submit From PR wizard. (Mirrors TC-PO-060209..TC-PO-060211 and TC-PO-010001.) |
One tb_purchase_order row created with po_type = purchase_request, po_status = draft; two tb_purchase_order_detail rows; two bridge rows in tb_purchase_order_detail_tb_purchase_request_detail written with pr_detail_qty > 0 per (po_line, pr_line) pair (PO_VAL_014); PR-side snapshot — product, UoM, location, delivery point — denormalised onto each bridge row at conversion; source PR flips approved → completed (PR_POST_007); URL changes from /new to PO detail. |
| PUR-HP-04 | Consolidate two approved PRs with same vendor + currency into one PO | Two approved PRs (PR-A 1 line, PR-B 1 line) where both target vendor_id = V1 and currency_id = THB |
1. Open Create Purchase Order → From PR. 2. Tick both PRs in wizard step 1. 3. Wizard step 2 auto-groups by (vendor_id, currency_id) → one draft-PO group for V1 / THB containing both lines. 4. Submit From PR wizard. |
One tb_purchase_order row for V1 / THB with two tb_purchase_order_detail lines; one bridge row per (po_line, pr_line) pair written; both source PRs flip approved → completed per PR_POST_007; grouping key respects the single-vendor / single-currency invariant PO_VAL_013. |
| PUR-HP-05 | Amend a sent PO under the post-sent restrictions |
PO at po_status = sent (final approval fired and Send to Vendor completed); vendor agreed to a quantity reduction on L1 from 10 to 8; received_qty = 0 on L1 |
1. Open the PO detail. 2. For L1 set cancelled_qty = 2 so received_qty + cancelled_qty = order_qty − 0 = 10 − 2. 3. Add a comment in tb_purchase_order_comment describing the agreed amendment and the vendor's acknowledgement. 4. Save. |
Per PO_VAL_016, only cancelled_qty and per-line notes are mutable post-sent; cancelled_qty = 2 persisted on L1; po_status stays sent (no status change); an audit comment is appended; effective open qty becomes order_qty − received_qty − cancelled_qty = 10 − 0 − 2 = 8; downstream GRN against L1 may now receive up to 8. |
| PUR-HP-06 | Save a draft and resume on the next session | Purchaser fills header + one line on a manual PO, saves draft, and logs out mid-form | 1. Save Draft (TC-PO-060203). 2. Log out. 3. Log back in → Sidebar → Purchase Order → filter by Draft (TC-PO-060103). 4. Open the same po_no. 5. Click Edit (TC-PO-060401). |
Draft loads with header, line, totals, attachments, and comments exactly as saved; doc_version unchanged from save; edit mode active with Save / Cancel visible; line content can be modified (qty, price, discount, tax) per PO_AUTH_002; on second save doc_version increments and last_action_at_date = now(). |
| PUR-HP-07 | Submit a draft PO for approval | Draft PO from PUR-HP-02 with four lines, all validations passing; below the tenant high-value threshold so Purchaser may self-approve per PO_AUTH_004 |
1. Open the draft → Submit (TC-PO-060405). 2. Confirm in the submit dialog. | po_status transitions draft → in_progress per PO_POST_002; last_action = submitted, last_action_at_date = now(), last_action_by_id = current user; workflow_history initialised; workflow_current_stage advances to the first approval stage; user_action.execute populated from the workflow stage definition; all roll-ups recomputed per PO_CALC_008–PO_CALC_011; history entry appended; PO drops from the Draft filter and surfaces under In Progress. |
| PUR-HP-08 | Transmit an approved PO to the vendor | PO at po_status = sent after final approval (TC-PO-060901's mid-step — FC approves via approveAsFC); vendor email present in tb_vendor.email; send-to-vendor channel configured |
1. Open the approved PO (TC-PO-060501 — Send to Vendor + Close buttons present). 2. Click Send to Vendor (TC-PO-060502). 3. Confirm. | tb_purchase_order.email is set, approval_date = now(); the PO is transmitted via the configured channel (email / EDI / portal) under PO_AUTH_006; status remains sent (already moved by PO_POST_004 on final approval); toast confirms transmission; from this point the PO is a vendor-facing commitment and the soft budget commitment hardens into a vendor liability. |
| PUR-HP-09 | Full Purchaser golden journey end-to-end | Active vendor + products; FC approver available for cross-context approval | 1. Create blank PO with header + 1 line → Save Draft. 2. Submit (draft → in_progress). 3. FC approves via cross-context fixture (approveAsFC), driving in_progress → sent. 4. As Purchaser, click Send to Vendor. (Mirrors TC-PO-060901.) |
End state po_status = sent; tb_purchase_order.email, approval_date set; history shows created → submitted → approved → sent; PO visible under Open POs dashboard for fulfilment monitoring; receipt-driven transitions (sent → partial → completed via PO_POST_006 / PO_POST_007) are monitored — not driven — by the Purchaser. |
| # | Scenario | Expected behaviour (allow/deny + reason) |
|---|---|---|
| PUR-PERM-01 | Purchaser opens own PO (current user matches tb_purchase_order.buyer_id, or is in user_action.execute[] at the current workflow_current_stage) |
Allow read across all statuses and the create / edit / submit / transmit actions per PO_AUTH_001–PO_AUTH_003 and PO_AUTH_006. Detail page loads with header + items + Item Details panel (TC-PO-060301, TC-PO-060302). |
| PUR-PERM-02 | Purchaser views another Purchaser's PO (RBAC-scoped read) | Allow read when the tenant's RBAC grants purchase-order:read to all enum_stage_role = purchase users (default); deny write actions (Edit / Submit / Send-to-Vendor / Close) because PO_AUTH_002 requires the user to be the assigned buyer or hold the current workflow_current_stage. Direct API call to edit returns "You are not authorised to edit this PO at the current stage". |
| PUR-PERM-03 | Purchaser edits a draft PO they own |
Allow per PO_AUTH_002 (po_status ∈ {draft, in_progress} and user is the assigned buyer). All header and line fields editable; Edit / Delete / Submit buttons present on draft (TC-PO-060303); modifying qty / adding lines / cancelling edit / submitting all succeed (TC-PO-060401..TC-PO-060405). |
| PUR-PERM-04 | Purchaser edits a sent PO they own |
Allow with field restriction per PO_VAL_016 — only cancelled_qty and per-line note are mutable post-sent. Vendor / currency / line price / line qty edits are rejected with "PO can no longer be amended at status sent. Void or close instead."; UI shows the detail page in read-only mode for header and line price / qty (TC-PO-060304 best-effort) with an Amendment panel exposing the allowed fields. |
| PUR-PERM-05 | Purchaser attempts to approve their own PO (in_progress → sent self-approval where total ≤ tenant high-value threshold) |
Allow only if the workflow definition assigns the Purchaser to the approval stage (per PO_AUTH_004 — "below the threshold, Procurement Officer can self-approve to sent if the workflow allows"). Deny when the workflow routes in_progress to the Procurement Manager regardless of amount, or when segregation of duties (PO_AUTH_010) is configured to force a different approver. Direct call to advance the stage from a non-listed user is rejected by PO_AUTH_011 with "You are not in the authorised approver set for this stage". |
| PUR-PERM-06 | Purchaser voids own draft PO via soft-delete (abandon path) |
Deny direct hard-delete; allow escalation. PO_AUTH_005 reserves the soft-delete-in-draft action to the Procurement Manager. The Purchaser's path is to abandon-as-draft (leave the PO in draft indefinitely) or escalate to the Procurement Manager for soft-delete. Direct API call to delete from the Purchaser role is rejected with "Soft-delete from draft is restricted to the Procurement Manager". UI exposes a Delete button on draft (TC-PO-060303) but the API enforces the role check. |
| PUR-PERM-07 | Purchaser attempts to void a sent or partial PO |
Deny — Manager-only. Per PO_AUTH_007, void from any non-draft non-terminal status (in_progress, sent, partial) is reserved for the Procurement Manager. The Void action is hidden / disabled on the Purchaser's detail page for non-draft POs; a direct API call returns "Void from status <status> requires the Procurement Manager role". |
| PUR-PERM-08 | Purchaser attempts to close a partial PO (early termination) |
Deny — Receiver / Manager only. Per PO_AUTH_008, the Close (partial → closed) action is granted to the Inventory Manager / Receiver who runs GRN posting; the Procurement Manager may also close via PO_POST_011. The Purchaser may click Close on the approved PO detail (TC-PO-060503, TC-PO-060504) only when the workflow elevates them temporarily — by default the call is rejected with "You are not authorised to close this PO". |
| # | Scenario | Trigger | Expected error |
|---|---|---|---|
| PUR-VAL-01 | Submit a PO without a po_no (DB-level fallback) |
Force a save with po_no blank via direct API call (UI generates one by default) |
Reject — PO_VAL_001. Server returns "PO reference number is required and must be unique."; the DB unique index @@unique([po_no, deleted_at]) is the last-resort guard. |
| PUR-VAL-02 | Submit a PO whose vendor_id references a soft-deleted or inactive tb_vendor row |
Pick vendor V_old whose deleted_at IS NOT NULL or is_active = false; save and submit |
Reject — PO_VAL_002. Server returns "Vendor is required and must be from the approved vendor list."; UI vendor picker filters out inactive and deleted vendors but a stale selection caught at submit triggers the validation. |
| PUR-VAL-03 | Submit a PO with exchange_rate = 0 or negative |
Set exchange_rate = 0 on the header and try to save / submit |
Reject — PO_VAL_003. Server returns "Transaction currency and a positive exchange rate are required."; UI input enforces > 0 but a direct API call is caught by the validation. |
| PUR-VAL-04 | Submit a PO with delivery_date < order_date |
Set order_date = 2026-05-15, delivery_date = 2026-05-14; click Save / Submit |
Reject — PO_VAL_006. Server returns "Delivery date must be on or after the order date."; UI date picker warns inline; submit attempt blocked. |
| PUR-VAL-05 | Submit a PO with a line order_qty = 0 |
Set order_qty = 0 on one line; click Submit |
Reject — PO_VAL_008. Server returns "Order quantity must be greater than zero and a unit of measure is required."; the line is flagged in red in the items table. |
| PUR-VAL-06 | Submit a PO line with price < 0, or price = 0 without the FOC flag |
Set price = -10 on a line, or price = 0 with is_foc = false; click Submit |
Reject — PO_VAL_010. Server returns "Unit price must be non-negative; price of 0 requires the FOC flag."; UI shows the inline error on the line. |
| PUR-VAL-07 | Save a draft PO without any line items, then attempt to submit | From the Blank create wizard, save header only with no lines, then click Submit | Reject — PO_VAL_012. Server returns "PO must contain at least one line item."; UI Save button stays disabled or the page stays on /new when no lines are present (TC-PO-060204). |
| PUR-VAL-08 | Attempt to submit a PO whose lines mix vendors or currencies | Add line L1 with vendor_id = V1, currency_id = THB, and line L2 with vendor_id = V2, currency_id = THB (forced via direct API call — UI prevents the mixed-vendor selection by binding lines to the header vendor_id) |
Reject — PO_VAL_013. Server returns "All lines on a PO must share the header vendor and currency. Split into separate POs by vendor+currency."; the single-vendor / single-currency invariant is enforced at submit. |
| PUR-VAL-09 | Convert an approved PR to a PO but the bridge row is missing | Force a direct insert of a tb_purchase_order_detail row with po_type = purchase_request on the parent PO and no entry in tb_purchase_order_detail_tb_purchase_request_detail; click Submit |
Reject — PO_VAL_014. Server returns "PR-sourced PO lines must be linked to an originating PR line via the bridge table."; the From-PR wizard always writes the bridge row, so this validation primarily guards direct-API / data-fix paths. |
| PUR-VAL-10 | Attempt to amend vendor_id or currency_id on a sent PO |
Open the PO at po_status = sent and try to change the header vendor or currency via direct API call (UI disables the fields) |
Reject — PO_VAL_016. Server returns "PO can no longer be amended at status sent. Void or close instead."; only cancelled_qty and per-line notes are mutable post-sent. |
| # | Scenario | Condition | Expected |
|---|---|---|---|
| PUR-EDGE-01 | Zero-quantity submit-time guard combined with line save-time guard | Purchaser tries to save a line with order_qty = 0 (caught at save by PO_VAL_008); then tries to bypass UI by submitting the PO with the line edited to 0 post-save via direct API call |
Both the save-line and submit-time runs of PO_VAL_008 reject the line with "Order quantity must be greater than zero and a unit of measure is required."; submit aborts; line stays at the prior valid value; no tb_purchase_order_detail row is mutated. |
| PUR-EDGE-02 | Maximum decimal precision on line price |
Line price = 99999999999.99999 (15 integer + 5 fraction digits, the upper bound of Decimal(20, 5)) with order_qty = 1.99999; tax_rate 7%, no discount |
Accepted at storage; sub_total_price = Round(99999999999.99999 × 1.99999, 2) computed per PO_CALC_001; rounding uses half-up via PO_CALC_012; base_qty = Round(1.99999 × conv_factor, 3) per PO_CALC_006; header total_qty aggregates in base UoM at 3 dp per PO_CALC_011; all stored Decimals fit within Decimal(20, 5) for money and Decimal(15, 5) for rates. |
| PUR-EDGE-03 | Back-dated delivery_date equal to order_date (lower bound of PO_VAL_006) |
Set order_date = 2026-05-15, delivery_date = 2026-05-15; click Save / Submit |
Accepted — PO_VAL_006 enforces delivery_date >= order_date (inclusive). The same-day case passes; PO saves and submits normally. Setting delivery_date = order_date − 1d would fail (covered by PUR-VAL-04). |
| PUR-EDGE-04 | Multi-currency consolidation rejection in the From-PR wizard | Tick two approved PRs in the wizard: PR-A with currency_id = THB and PR-B with currency_id = USD, both for vendor_id = V1 |
The wizard auto-groups by (vendor_id, currency_id) → two draft-PO groups (one per currency), not one. Each resulting PO satisfies PO_VAL_013. A direct API call to force both lines into a single PO is rejected with "All lines on a PO must share the header vendor and currency. Split into separate POs by vendor+currency." (PO_VAL_013). |
| PUR-EDGE-05 | Concurrent edits on the same draft PO from two Purchaser sessions | Purchaser A and Purchaser B both load draft PO PO-X at doc_version = 3; A saves a qty change at T (doc_version → 4); B saves a price change at T + 1s using the stale doc_version = 3 |
First save wins: A's qty change is persisted, doc_version = 4. B's save is rejected by the optimistic-concurrency guard with "This PO was modified by another user. Please refresh and re-apply your changes."; B's client prompts a refresh. No double-write or lost-update is created. |
| PUR-EDGE-06 | Transmit while vendor email is missing / vendor flipped inactive | PO at po_status = sent; tb_vendor.email IS NULL for the vendor on the PO, or tb_vendor.is_active = false after the PO was approved |
Send to Vendor (TC-PO-060502) on a vendor with missing email is rejected with "Vendor email is not configured — set up the transmission channel before sending."; on an inactive / blacklisted vendor it is rejected with "Vendor is no longer active and cannot receive new POs." The PO stays sent (the status transition already fired at final approval per PO_POST_004); the email / approval_date columns are not updated; an audit comment is appended. The Purchaser must either escalate to the Procurement Manager for a void (PO_AUTH_007) or update the vendor master and retry. |
| PUR-EDGE-07 | Submit a PO immediately after a soft-delete on the same po_no (uniqueness re-use) |
A prior PO with po_no = PO-2026-000123 was soft-deleted (deleted_at IS NOT NULL) per PO_POST_012; the Purchaser drafts a new PO and the numbering service issues the same po_no |
Accepted — the unique index @@unique([po_no, deleted_at]) allows reuse because deleted_at differs. New PO saves and submits normally; the prior soft-deleted row remains in the database for audit. Hard-delete-in-draft is reserved to the Procurement Manager (PO_AUTH_005) so this scenario originates from a Manager-driven cleanup. |
X-PO-01 (full happy path from PR), X-PO-02 (manual PO), X-PO-05 (amendment cycle on Sent PO), X-PO-10 (send-back during approval).sent amendment restrictions.PO_VAL_001–PO_VAL_016 — validation rules covered one-for-one in Section 3 above), Section 3 (PO_CALC_001–PO_CALC_012 — line and header roll-ups, FOC handling, base-currency dual-posting, rounding referenced in PUR-HP-02 and PUR-EDGE-02), Section 4 (PO_AUTH_001–PO_AUTH_003 Purchaser create / edit / submit; PO_AUTH_004 self-approval threshold; PO_AUTH_005 Manager-only delete; PO_AUTH_006 Purchaser or Manager transmit; PO_AUTH_007 Manager-only void; PO_AUTH_010 segregation of duties; PO_AUTH_011 workflow-stage authorisation), Section 5 (PO_POST_001–PO_POST_004 create / submit / approve / final approval; PO_POST_012 soft-delete).tb_purchase_order_detail_tb_purchase_request_detail (many-to-many PO↔PR line linkage supporting consolidation and partial conversion); column types Decimal(20, 5) for money / Decimal(15, 5) for rates referenced in PUR-EDGE-02.../carmen-inventory-frontend-e2e/tests/402-po-purchaser-journey.spec.ts — persona-journey spec for the Purchaser. Covers TC-PO-060101..TC-PO-060105 (Step 1 list), TC-PO-060201..TC-PO-060212 (Step 2 create — Blank / From Price List / From PR), TC-PO-060301..TC-PO-060304 (Step 3 detail), TC-PO-060401..TC-PO-060406 (Step 4 edit mode), TC-PO-060501..TC-PO-060504 (Step 5 post-approval — Send to Vendor / Close), TC-PO-060901 (Golden Journey full Purchaser flow). Shared / mixed-persona coverage in ../carmen-inventory-frontend-e2e/tests/401-po.spec.ts (TC-PO-010001..TC-PO-010004 create-from-PR happy + negatives, TC-PO-020001..TC-PO-020005 manual create, TC-PO-030001..TC-PO-030004 send to vendor, TC-PO-040001..TC-PO-040004 change order, TC-PO-050001..TC-PO-050004 cancel, TC-PO-060001..TC-PO-060004 dashboard, TC-PO-200001..TC-PO-200004 QR code, TC-PO-310001..TC-PO-340003 backend / calc / GRN-sync scenarios).sent → partial → completed receipt transitions monitored (not driven) by the Purchaser.