At a Glance
Persona: Purchaser (Purchasing Staff + Purchasing Manager) · Module: vendor-pricelist · Scenarios: ~39
Categories: Happy Path · Permission · Validation · Edge Case
E2E coverage: no dedicated vendor-pricelist spec; From-Price-List wizard exercised viatests/402-po-purchaser-journey.spec.ts(TC-PO-060205..060208) in../carmen-inventory-frontend-e2e/
This page captures the test scenarios that the Purchaser persona (consolidating Purchaser / Purchasing Staff + Purchasing Manager per 03-user-flow-purchaser.md) directly drives in the vendor-pricelist module. The Purchaser owns the full operational lifecycle on the Carmen side: template create / edit / activate, campaign launch / pause / cancel, invitation issuance, manual upload of email-submitted pricelists, submitted-pricelist review and approval / rejection, preferred-vendor curation, and inactivation. The Purchasing Manager role on the same persona file extends with high-value approval, business-rule configuration, and multi-currency sign-off authority. Scenarios are grouped into the happy paths described in the user flow, the RBAC boundary enforced by VPL_AUTH_001–VPL_AUTH_006 (Purchaser scope) versus VPL_AUTH_005–VPL_AUTH_006 (Manager elevation), the validation rules in 02-business-rules.md § 2 (VPL_VAL_001–VPL_VAL_025) that the Purchaser can trigger across template / campaign / pricelist surfaces, and a small set of edge cases around concurrency, MOQ boundaries, currency edge values, and snapshot semantics during inactivation. Cross-persona handoffs that pivot off the Purchaser (X-VPL-01, X-VPL-02, X-VPL-03, X-VPL-04, X-VPL-05, X-VPL-09, X-VPL-10, X-VPL-12) live in the parent overview, not here.
| # | Scenario | Pre-condition | Steps | Expected |
|---|---|---|---|---|
| VPL-PUR-HP-01 | Create + activate a minimal template | Purchaser purchase@blueledgers.com logged in; active products P1, P2 in tb_product; active currency THB in tb_currency |
1. Sidebar → Templates → New Template. 2. Fill name = Q2-2026-Beverages, default currency_id = THB, validity_period = 90, reminder_days = [14, 7, 3, 1]. 3. Products tab → add P1 + P2 with default order unit Each and MOQ tiers [{qty:1},{qty:50},{qty:100}]. 4. Activate. |
tb_pricelist_template row inserted with status = draft then transitioned to active via VPL_POST_002; doc_version incremented; two tb_pricelist_template_detail rows with order_unit_obj carrying the MOQ tier definitions per VPL_VAL_006; activation system comment appended in tb_pricelist_template_comment; template now selectable for campaigns. |
| VPL-PUR-HP-02 | Launch a campaign on an active template | Active template from VPL-PUR-HP-01; two vendors V1 + V2 in tb_vendor with valid contact emails; email template EMAIL-PL-INVITE-EN exists in tenant registry |
1. Sidebar → Campaigns → New Campaign. 2. Pick template Q2-2026-Beverages. 3. Fill name = Q2-2026-Beverages-Campaign-01, start_date = today, end_date = today + 30 days, custom_message, email_template_id = EMAIL-PL-INVITE-EN. 4. Select vendors V1 + V2. 5. Launch. |
tb_request_for_pricing row inserted; two tb_request_for_pricing_detail rows materialised with fresh cryptographic pricelist_url_token per row per VPL_VAL_011–VPL_VAL_012; invitation emails dispatched via configured channel; campaign status derived active (VPL_POST_006); dispatch system comments appended on each invitation row capturing email-send telemetry. |
| VPL-PUR-HP-03 | Review and approve a submitted pricelist (below high-value threshold) | Submitted pricelist PL-V1-Q2 from vendor V1 at tb_pricelist.status = draft, submitted_at IS NOT NULL; projected aggregate value below tenant high-value threshold; validator quality_score = 85 (above the default threshold of 70) |
1. Sidebar → Pricelists → Submitted (awaiting review) queue. 2. Open PL-V1-Q2. 3. Review the Validation panel and the detail rows; multi-MOQ rows for P1 are auto-sorted non-increasing per VPL_VAL_020. 4. Click Approve. |
VPL_POST_017 fires: tb_pricelist.status = draft → active; activation system comment appended in tb_pricelist_comment; pricelist becomes the live reference for (V1, P1, THB) and (V1, P2, THB) within its validity window; downstream PR-line defaulting reads it on next request per VPL_XMOD_001. |
| VPL-PUR-HP-04 | Toggle preferred-vendor flag across competing pricelists | Two active pricelists PL-V1 and PL-V2, both covering product P1 in THB at MOQ 1; Purchaser decides V2 is the preferred source |
1. Sidebar → Vendor Management → Preferred-Vendor Matrix. 2. For cell (P1, THB, MOQ 1), currently PL-V1.is_preferred = true. 3. Toggle PL-V2.is_preferred = true; system auto-toggles PL-V1.is_preferred = false to maintain the one-preferred-per-cell invariant. 4. Save. |
tb_pricelist_detail.is_preferred updated on both rows; per-row system comments appended in tb_pricelist_detail_comment recording the change; PR-line defaulting now sources from PL-V2 on next read; Manager sign-off captured if the toggle exceeds the configured authorisation per VPL_AUTH_005. |
| VPL-PUR-HP-05 | Reject submission with reason; vendor resubmits | Submitted pricelist PL-V3-Q2 from vendor V3; validator flagged a row with price_without_tax = 0 without an FOC explanation |
1. Open PL-V3-Q2. 2. Click Reject. 3. Enter reason: "Row 7 has zero price without FOC justification — please confirm or correct." 4. Confirm. |
VPL_POST_018 fires: tb_pricelist.submitted_at reset to NULL; pricelist stays at status = draft; rejection system comment appended in tb_pricelist_comment with reason text; vendor receives rejection email; portal token remains valid until campaign end_date; pricelist re-enters editable state on the vendor's portal session. |
| VPL-PUR-HP-06 | Manually upload an emailed Excel pricelist on vendor's behalf | Vendor V4 has confirmed email-only submission; campaign Q2-Campaign-01 is active; V4's invitation row has token issued but unused |
1. Open campaign → V4 invitation row → Upload pricelist on vendor's behalf. 2. Drop the vendor's returned Excel workbook. 3. Application parses it into tb_pricelist + tb_pricelist_detail rows with submission_method = email. 4. Save and submit. |
Pricelist created at status = draft with submission_method = email, submitted_at = now(); system comment in tb_pricelist_comment records the email source + uploading staff identity per VPL_AUTH_003; pricelist enters standard review flow at VPL-PUR-HP-03. Invitation row's pricelist_id FK is populated; invitation status derived submitted (VPL_POST_012). |
| VPL-PUR-HP-07 | Inactivate an active pricelist (price out-of-date) | Pricelist PL-V1-Q1 at status = active; Finance flagged a systematic over-bill pattern; new campaign launched in parallel |
1. Open PL-V1-Q1. 2. Click Inactivate under VPL_AUTH_001. 3. Confirm. |
VPL_POST_019 fires: tb_pricelist.status = active → inactive; inactivation system comment appended; downstream PR / PO / GRN consumers treat as historical-only from this point; existing PR lines that already defaulted from this pricelist retain their snapshotted price per purchase-order/02-business-rules § PO_XMOD_005; new PR-line creations for (V1, P1, THB) either default from a fallback active pricelist (e.g., PL-V2) or set pricelist_type = manual_input per VPL_XMOD_002 if no fallback exists. |
| VPL-PUR-HP-08 | Manager approves a high-value pricelist | Purchasing Manager pm@blueledgers.com logged in; submitted pricelist PL-V5-HV whose projected aggregate exceeds the tenant high-value threshold (e.g., ฿5,000,000); Purchaser flagged for Manager review |
1. Open Manager review queue. 2. Open PL-V5-HV. 3. Review header + validation + detail rows + the threshold-exceeded indicator. 4. Click Approve. |
VPL_POST_017 fires under VPL_AUTH_005; activation system comment captures Manager identity. The Purchaser is notified; pricelist activates; downstream readiness as in VPL-PUR-HP-03. |
| VPL-PUR-HP-09 | Cancel a campaign mid-flight | Campaign Q2-Campaign-01 at derived active; Purchaser decides to abort due to a template error; one vendor has already submitted, two have not |
1. Open campaign → Cancel campaign. 2. Enter reason: "Template product list incomplete — relaunching with v2." 3. Confirm. | VPL_POST_009 fires (application-flag in info JSON): campaign moves to derived cancelled; cancellation system comment in tb_request_for_pricing_comment; all portal tokens revoked (set to NULL on tb_request_for_pricing_detail); cancellation emails dispatched to all invited vendors; the one submitted-but-unreviewed pricelist remains in draft + submitted_at IS NOT NULL for the Purchaser's decision to (a) approve under the cancelled campaign's snapshotted rules, or (b) soft-delete and re-collect under the relaunched campaign. |
| VPL-PUR-HP-10 | Full Purchaser golden journey end-to-end | Active products + vendor; tenant config standard | 1. Create template + activate (VPL-PUR-HP-01). 2. Launch campaign (VPL-PUR-HP-02). 3. Wait for vendor submission. 4. Approve (VPL-PUR-HP-03). 5. Toggle preferred-vendor flag if competing pricelists exist (VPL-PUR-HP-04). 6. Monitor downstream consumption on the Active Pricelists dashboard. | End state: template active; campaign derived completed (after end_date); pricelist active; downstream PR-line defaulting confirmed on a representative new PR. Activity log shows the canonical chain: template-created → activated → campaign-launched → invitation-dispatched → portal-opened → vendor-saved → vendor-submitted → reviewed → approved → preferred-flag-toggled. |
| # | Scenario | Expected behaviour (allow/deny + reason) |
|---|---|---|
| VPL-PUR-PERM-01 | Purchaser creates / edits a template they own (draft or active) |
Allow per VPL_AUTH_001. Template surface exposes Save, Edit, Activate, Inactivate. The Manager role inherits all Purchaser rights plus the elevated authorities below. |
| VPL-PUR-PERM-02 | Purchaser launches a campaign referencing an inactive template |
Deny per VPL_VAL_009. Server returns "Campaign must reference an active template — draft or inactive templates cannot drive campaigns." UI hides inactive templates from the campaign-template picker; direct API call is caught by validation. |
| VPL-PUR-PERM-03 | Purchaser approves a pricelist below the high-value threshold | Allow per VPL_AUTH_004. VPL_POST_017 fires; pricelist activates. |
| VPL-PUR-PERM-04 | Purchaser attempts to approve a high-value pricelist (above the tenant threshold) | Deny — Manager-only. Per VPL_AUTH_005, high-value approval requires the Purchasing Manager role. The Approve button is disabled in the Purchaser's UI; direct API call returns "High-value pricelist activation requires the Purchasing Manager role." Pricelist routes to the Manager review queue. |
| VPL-PUR-PERM-05 | Purchaser attempts to approve a multi-currency pricelist without Finance Manager co-signoff | Deny — co-approval required. Per VPL_AUTH_010, multi-currency activation requires the Finance Manager system co-signoff comment to be present on tb_pricelist_comment before VPL_POST_017 can fire. UI surfaces the co-signoff requirement; direct API call returns "Multi-currency pricelist requires Finance Manager co-signoff before activation." |
| VPL-PUR-PERM-06 | Purchaser attempts to edit a row on an active pricelist |
Deny. Per VPL_VAL_025, active pricelist is immutable except for status. Server returns "Active pricelist is immutable — open a new pricelist via a fresh campaign, or move this pricelist to inactive first." UI exposes only Inactivate + comment-add on active pricelists; direct API edit is blocked. |
| VPL-PUR-PERM-07 | Purchaser attempts to revoke a vendor portal token directly | Deny — Sysadmin / Manager only. Per VPL_AUTH_015, token revocation is reserved to the System Administrator (or Purchasing Manager via escalation). UI does not expose the Revoke action to the Purchaser; the Purchaser's path is to escalate to the Sysadmin / Manager with the rationale. Direct API call returns "Token revocation requires the System Administrator or Purchasing Manager role." |
| VPL-PUR-PERM-08 | Purchaser uploads an emailed pricelist on a vendor's behalf | Allow per VPL_AUTH_003. The upload writes submission_method = email and a mandatory system comment recording the email source and the uploading staff identity. |
| VPL-PUR-PERM-09 | Purchaser attempts to bypass segregation of duties (same user creates pricelist rows AND approves at high-value) | Deny. Per VPL_AUTH_014, the user who substantially edited a high-value pricelist's detail rows cannot be the same user who approves it. Enforced at the VPL_POST_017 transition. Server returns "Segregation of duties: cannot approve a high-value pricelist you have substantially edited." |
| # | Scenario | Trigger | Expected error |
|---|---|---|---|
| VPL-PUR-VAL-01 | Create a template with a duplicate name | Pick name that already exists on a non-soft-deleted tb_pricelist_template row |
Reject — VPL_VAL_001. Server returns "Template name is required and must be unique." DB-level fallback via pricelist_template_name_deletedat_u. |
| VPL-PUR-VAL-02 | Activate a template with no product rows | Open a draft template with tb_pricelist_template_detail count = 0; click Activate |
Reject — VPL_VAL_002. Server returns "Template must contain at least one product before it can be activated." UI Activate button is disabled until at least one product is added. |
| VPL-PUR-VAL-03 | Save a template detail row with MOQ qty = 0 or with descending MOQ values |
Add a product with moq: [{qty:50}, {qty:1}] (descending) or [{qty:0}, {qty:50}] |
Reject — VPL_VAL_006. Server returns "Order unit and at least one MOQ tier with positive quantity are required; MOQ quantities must be strictly increasing." |
| VPL-PUR-VAL-04 | Launch a campaign with start_date >= end_date |
Set start_date = 2026-06-15, end_date = 2026-06-10; click Launch |
Reject — VPL_VAL_010. Server returns "Campaign window must run for at least the tenant minimum (default 3 days) and end strictly after start; end_date must be in the future at launch." |
| VPL-PUR-VAL-05 | Launch a campaign with no vendors selected | Open New Campaign, fill header, select zero vendors, click Launch | Reject — VPL_VAL_011. Server returns "Campaign must invite at least one vendor and every invited vendor must have a contact email." |
| VPL-PUR-VAL-06 | Add the same vendor twice to a campaign | Select vendor V1, then try to add V1 again via the vendor picker |
Reject — VPL_VAL_012. Server returns "Vendor has already been invited to this campaign." UI prevents duplicate selection; DB-level fallback via request_for_pricing_detail_request_for_pricing_id_vendor_id_u. |
| VPL-PUR-VAL-07 | Approve a submitted pricelist whose row has price < price_without_tax + tax_amt |
Force a row where price = ฿10.00 but price_without_tax = ฿10.00 and tax_amt = ฿0.70; attempt to approve |
Reject — VPL_VAL_021. Server returns "Price-without-tax must be non-negative; tax_amt and price must reconcile to the configured rounding rule (5 dp internal, 2 dp display)." Pricelist returns to vendor for correction (or Purchaser can correct inline under VPL_AUTH_001). |
| VPL-PUR-VAL-08 | Submit a pricelist with descending MOQ-tier prices (higher MOQ at higher unit price) | Vendor (or Purchaser uploading on behalf) submits product P1 with three rows: MOQ 1 @ ฿10.00, MOQ 50 @ ฿11.00, MOQ 100 @ ฿12.00 |
Reject at submit — VPL_VAL_020. Server returns "MOQ-tier pricing must be non-increasing as MOQ quantity increases." Save (vs submit) emits a warning; submit blocks until corrected. |
| VPL-PUR-VAL-09 | Submit a pricelist with effective_to_date <= effective_from_date |
Set effective_from_date = 2026-07-01, effective_to_date = 2026-06-30; click submit |
Reject — VPL_VAL_016. Server returns "Effective-from date must precede effective-to date; effective-from cannot precede submission." |
| VPL-PUR-VAL-10 | Submit a pricelist with currency_id not in the tenant-permitted list |
Vendor's portal session selected BTC (or any other unpermitted currency) |
Reject — VPL_VAL_015 + VPL_AUTH_008. Server returns "Vendor and currency are required and must reference active master-data records." (Generic message; the application surfaces "Currency not permitted for tenant" to the vendor inline.) |
| VPL-PUR-VAL-11 | Approve a submitted pricelist via direct API while submitted_at IS NULL |
Vendor saved a draft but never clicked Submit; force a direct API call to approve | Reject — VPL_VAL_023 + the implicit state guard. Server returns "Pricelist must contain at least one valid line item to submit." (UI never offers Approve on a non-submitted draft; the API guard is the fallback.) |
| VPL-PUR-VAL-12 | Inactivate a pricelist that is referenced by an in-flight PR-line manual_input fallback chain |
Active pricelist PL-V1; downstream PR has a line with pricelist_detail_id pointing to PL-V1; attempt inactivation while the PR is mid-approval |
Allow the inactivation (snapshot semantics protect the PR line). Pricelist transitions active → inactive per VPL_POST_019; the PR line retains its snapshotted price; only new PR lines from the inactivation moment forward fall through to the fallback chain. Validation does not block this case; the activity log records the inactivation cleanly. |
| # | Scenario | Condition | Expected |
|---|---|---|---|
| VPL-PUR-EDGE-01 | Concurrent edits on the same draft template from two Purchaser sessions | Purchaser A and B both load draft template T1 at doc_version = 3; A saves a product addition at T (doc_version → 4); B saves a renaming at T + 1s using the stale doc_version = 3 |
First save wins: A's addition is persisted, doc_version = 4. B's save is rejected by the optimistic-concurrency guard with "This template was modified by another user. Please refresh and re-apply your changes."; B's client prompts a refresh. No lost update. |
| VPL-PUR-EDGE-02 | Maximum decimal precision on row price |
Pricelist detail row with price_without_tax = 99999999999.99999 (15 + 5 digits, upper bound of Decimal(20, 5)), tax_rate = 0.07000, moq_qty = 1 |
Accepted at storage. tax_amt = Round(99999999999.99999 × 0.07, 5) per VPL_CALC_001; price = Round(price_without_tax + tax_amt, 5) per VPL_CALC_002; all stored Decimals fit within their declared precision; display rounds to 2 dp on rendering. Effective unit price per base UoM computed correctly via VPL_CALC_003. |
| VPL-PUR-EDGE-03 | MOQ-tier boundary case — equal price across two adjacent tiers | Vendor submits MOQ 1 @ ฿10.00 and MOQ 50 @ ฿10.00 (same price; vendor not yet offering bulk discount but committed to the larger MOQ) |
Accepted — VPL_VAL_020 requires non-increasing (price[i+1] <= price[i]), not strictly decreasing. The two adjacent rows both pass with price[2] == price[1]. Purchaser surface shows them as "no bulk discount yet" annotation; PR-line preferred-vendor lookup picks either row depending on MOQ bracket. |
| VPL-PUR-EDGE-04 | Re-activation of an expired pricelist within window | Pricelist PL-V1-Q1 auto-expired at effective_to_date = 2026-04-30 (status = expired); on 2026-04-15 (still within window) the Sysadmin / Purchaser re-activates because the auto-expire fired prematurely |
Re-activation VPL_POST_020 allowed within window only; server checks now() < effective_to_date. On success, status = expired → active (an exceptional transition not in the standard state diagram); a system comment captures the cause and actor. Outside the window, the re-activation is rejected with "Cannot re-activate an expired pricelist outside its validity window — open a new pricelist via a fresh campaign." |
| VPL-PUR-EDGE-05 | Soft-delete on a draft pricelist with portal session still open | Vendor's portal has an in-progress draft at tb_pricelist.status = draft; Purchaser soft-deletes the pricelist (e.g., as part of campaign cancellation cleanup) |
Soft-delete VPL_POST_022 allowed (status is draft). deleted_at = now(), deleted_by_id = user. Vendor's next portal save returns 404 — pricelist not found; the portal redirects to a "session terminated" page explaining the situation. Unique index includes deleted_at, so the same pricelist_no can be reused for a fresh pricelist under the relaunched campaign. |
| VPL-PUR-EDGE-06 | Numerical race on is_preferred toggle across two cells affecting the same product |
Purchaser A toggles preferred for (P1, THB, MOQ 1) from PL-V1 to PL-V2; concurrently Purchaser B toggles preferred for (P1, THB, MOQ 50) from PL-V1 to PL-V3 |
Both edits proceed independently because they operate on different tb_pricelist_detail rows (different moq_qty). The one-preferred-per-cell invariant is maintained per cell, not across MOQ tiers of the same product. PL-V1.is_preferred = false on both affected rows after the two saves. Activity log records both actors. |
| VPL-PUR-EDGE-07 | Pricelist numbering uniqueness reuse after soft-delete | A prior pricelist PL-2026-001 was soft-deleted (deleted_at IS NOT NULL); a new pricelist is created and the numbering service issues the same pricelist_no = PL-2026-001 |
Accepted — the unique index pricelist_pricelist_no_u is @@unique([pricelist_no, deleted_at]). New pricelist saves and submits normally; the prior soft-deleted row remains in the database for audit. |
| VPL-PUR-EDGE-08 | Email-template registry switch mid-campaign | Sysadmin changes the tenant default email-template at T; campaign launched at T - 1 hour referencing the now-old template id; reminder emails fire at T + 24 hours |
Campaign retains the snapshotted email_template_id at launch per snapshot semantics (vendor-pricelist/03-user-flow-audit-config § 4 exit). Reminder emails continue to use the snapshotted template; the Sysadmin's switch applies only to new campaigns launched after T. Audit log records the snapshot vs current divergence. |
X-VPL-01 (full happy path), X-VPL-02 (high-value via Manager), X-VPL-03 (multi-currency co-signoff), X-VPL-04 (reject + resubmit), X-VPL-05 (email-method), X-VPL-09 (low quality score), X-VPL-10 (audit-finding remediation), X-VPL-12 (correction via inactivate + new campaign).VPL_VAL_001–VPL_VAL_025 — template, campaign, and pricelist validation rules; one-for-one coverage in Section 3 above), § 3 (VPL_CALC_001–VPL_CALC_008 — line price decomposition, effective unit price, multi-currency display, quality score, rounding referenced in VPL-PUR-EDGE-02 and VPL-PUR-VAL-07), § 4 (VPL_AUTH_001–VPL_AUTH_006 Purchaser scope; VPL_AUTH_005–VPL_AUTH_006 Manager elevation; VPL_AUTH_014 segregation; VPL_AUTH_015 token-revocation escalation), § 5 (VPL_POST_002, VPL_POST_017, VPL_POST_018, VPL_POST_019, VPL_POST_020, VPL_POST_022 — all Purchaser-driven transitions; the auto-expire VPL_POST_021 is observed not driven).tb_pricelist_detail (pricelist_detail_pricelist_id_product_id_unit_id_moqqty_u) referenced in VPL-PUR-EDGE-03 and VPL-PUR-EDGE-06; the info JSON shape for quality_score and validation_results referenced in VPL-PUR-VAL-07 / VPL-PUR-HP-03; column types Decimal(20, 5) for money / Decimal(15, 5) for rates referenced in VPL-PUR-EDGE-02.../carmen-inventory-frontend-e2e/tests/402-po-purchaser-journey.spec.ts (TC-PO-060205..TC-PO-060208) exercises the read-side of pricelist consumption from the PO module. Pricelist-write coverage is at the API / integration level pending the roadmap item in ../carmen/docs/vendor-pricelist-management/tasks.md.VPL_XMOD_001 exercised indirectly in VPL-PUR-HP-03 / VPL-PUR-HP-04.VPL_XMOD_003 exercised indirectly in VPL-PUR-HP-07 inactivation.VPL_XMOD_005 exercised in the cross-persona scenario X-VPL-06.VPL_XMOD_006).