At a Glance
Persona: Store Keeper · Module: inventory-adjustment · Scenarios: ~38
Categories: Happy Path · Permission · Validation · Edge Case
E2E coverage: maps to501-grn.spec.ts,720-stock-issue.spec.tsin../carmen-inventory-frontend-e2e/
This page captures the test scenarios that the Store Keeper persona directly drives in the inventory-adjustment module. The Store Keeper initiates tb_stock_in (inbound write-on) and tb_stock_out (outbound write-off) documents, attaches evidence, picks the reason code, enters product / qty / lot data, and submits — landing in completed (below threshold, non-new-lot) or in_progress (above threshold, new-lot, or requires-quality-check reasons). Scenarios are grouped into happy paths (auto-approve stock-in / stock-out, new-lot routing, multi-line submission, lot override for expiry write-off), RBAC / permission (Store Keeper scope vs Inventory Controller scope, SoD on receive-then-write-off), validation (negative tests against ADJ_VAL_001–ADJ_VAL_014 the Store Keeper can trigger at submit time), and edge cases around threshold boundaries, decimal precision, concurrent posts, location-type gates, and reason-flag effects. Cross-persona handoffs that pivot off the Store Keeper (Scenarios 1, 2, 4, 9, 10, 11, 14, 15, 16 in the parent overview) live in 04-test-scenarios.md, not here.
| # | Scenario | Pre-condition | Steps | Expected |
|---|---|---|---|---|
| SK-HP-01 | Below-threshold stock-in for existing lot — auto-approve and post | Store Keeper sk@blueledgers.com logged in with tb_user_location scope for LOC-A; product P-1 has existing lot LOT-1 at LOC-A with cost_per_unit = ฿10.00 on its most recent cost layer; auto-approve threshold = ฿500.00; period open. |
1. Open Inventory Adjustment module → New Stock-In. 2. Pick reason FOUND_STOCK (type = stock_in). 3. Description "Bin check 2026-05-15 — surplus on lower shelf". 4. Add line: product_id = P-1, qty = 10, lot_no = LOT-1 (existing), cost_per_unit = ฿10.00 (auto-filled). 5. Submit. |
Document auto-advances draft → in_progress → completed per ADJ_POST_001 / ADJ_POST_002. One tb_inventory_transaction (inventory_doc_type = stock_in, inventory_doc_no = tb_stock_in.id); one detail (qty = 10, cost_per_unit = ฿10.00, total_cost = ฿100.00, current_lot_no = LOT-1); one inbound cost-layer (in_qty = 10, transaction_type = adjustment_in). On-hand at (LOC-A, P-1, LOT-1) advances by 10. Maps to parent Scenario 1. |
| SK-HP-02 | Below-threshold stock-out for breakage — auto-approve and FIFO outbound | SK-HP-01 state + LOT-1 at 10 units ฿10.00; product FIFO; threshold ฿500.00. |
1. New Stock-Out. 2. Reason BREAKAGE (type = stock_out). 3. Description "Bottle dropped during stock-take". 4. Line: product_id = P-1, qty = 2; preview shows FIFO pick from LOT-1 at ฿10.00. 5. Submit. |
Auto-approve. tb_stock_out.doc_status = completed; outbound inventory transaction; detail with qty = -2, cost_per_unit = ฿10.00, total_cost = -฿20.00, from_lot_no = LOT-1; outbound cost-layer (out_qty = 2, transaction_type = adjustment_out, lot_no = LOT-1). On-hand at LOT-1 reduced to 8. |
| SK-HP-03 | New-lot found-stock — always routes to Controller | Store Keeper has create authority; new lot LOT-NEW (no prior cost-layer at (P-1, LOC-A, LOT-NEW)); cost ฿8.00 from [vendor-pricelist](/en/inventory/vendor-pricelist) last-price; total ฿80.00 (below threshold). |
1. New Stock-In. 2. Reason FOUND_STOCK. 3. Description with cost defensibility note. 4. Line: product_id = P-1, qty = 10, new lot_no = LOT-NEW, expiry date for perishable, cost_per_unit = ฿8.00. 5. Submit. |
Per ADJ_AUTH_003, routes to Controller regardless of cost. doc_status = draft → in_progress; appears in Controller's queue; no inventory transaction yet; activity log {event: 'new_lot_create', requires_approval: true}. Handoff to Controller per 04-test-scenarios-inventory-controller.md IC-HP-*. Maps to parent Scenario 4. |
| SK-HP-04 | Multi-line stock-out within threshold — auto-approve | Multiple shortage items; aggregate cost ฿200.00 (below threshold); reason COUNT_SHORTAGE. |
1. New Stock-Out. 2. Reason COUNT_SHORTAGE. 3. Three lines: P-1 × 2 (฿20), P-2 × 1 (฿15), P-3 × 5 (฿165). 4. Submit. |
Aggregate cost = ฿200.00 < ฿500.00 → auto-approve. One outbound tb_inventory_transaction with three details; three outbound cost-layer rows (FIFO per line); on-hand reduced per product. |
| SK-HP-05 | Lot override for expiry write-off | Two lots of P-1 at LOC-A: LOT-1 (5 at ฿10.00, lot_seq_no = 1); LOT-2 (3 at ฿12.00, lot_seq_no = 2); LOT-2 has expired. |
1. New Stock-Out. 2. Reason EXPIRY_WRITE_OFF (type = stock_out, info.requiresQualityCheck may be true → routes to Controller; below assume false for happy auto-approve path). 3. Line: P-1, qty = 3, lot picker override → LOT-2 (not FIFO default LOT-1). 4. Submit. |
The override is recorded in info.lotOverride = LOT-2. Outbound cost-layer: out_qty = 3, cost_per_unit = ฿12.00, lot_no = LOT-2, from_lot_no = LOT-2; on-hand at LOT-2 reduced to 0; LOT-1 untouched. |
| SK-HP-06 | FOC stock-in for vendor-replacement | Vendor sends free-replacement for prior damaged lot; outside GRN / credit-note path; new lot LOT-FOC. |
1. New Stock-In. 2. Reason VENDOR_FREE_REPLACEMENT (type = stock_in). 3. Description with vendor RMA reference. 4. Line: P-1, qty = 5, new lot_no = LOT-FOC, expiry, cost_per_unit = ฿0.00. 5. Attachment: vendor RMA scan. 6. Submit. |
Per ADJ_AUTH_003 (new-lot), routes to Controller; cost = ฿0.00 total below threshold also routes (the rule overrides threshold). After Controller approval: zero-cost inbound row; WA dilutes per ADJ_CALC_005; FIFO new lot at lot_seq_no = max+1 cost_per_unit = 0. |
| SK-HP-07 | Requires-document attachment satisfied | Reason THEFT_WRITE_OFF with info.requiresDocument = true; security incident report attached. |
1. New Stock-Out. 2. Reason THEFT_WRITE_OFF. 3. Description with incident context. 4. Line: P-1, qty = 3. 5. Attach incident report PDF in comment area. 6. Submit. |
Per ADJ_VAL_010, attachment requirement satisfied → submit proceeds. Reason THEFT_WRITE_OFF likely info.requiresQualityCheck = true so routes to Controller per ADJ_AUTH_004 regardless of cost. |
| # | Scenario | Expected behaviour (allow/deny + reason) |
|---|---|---|
| SK-PERM-01 | Store Keeper creates stock-in / stock-out within scope, below threshold | Allow. Per ADJ_AUTH_001 / ADJ_AUTH_002, create authority within tb_user_location scope; below threshold auto-approves draft → completed per ADJ_POST_001 / ADJ_POST_002. |
| SK-PERM-02 | Store Keeper attempts to approve another Store Keeper's in_progress document |
Deny — Inventory Controller required. Per ADJ_AUTH_004, the in_progress → completed transition above auto-approve requires Inventory Controller (or Finance for above-Controller-threshold). API call returns "Approval of above-threshold adjustments requires the Inventory Controller role." Document stays in_progress. |
| SK-PERM-03 | Store Keeper attempts to edit a completed document |
Deny — terminal state. Per ADJ_VAL_013 and inventory INV_POST_012, completed documents are immutable. PATCH /stock-in/{id} (or /stock-out/{id}) returns "Cannot edit a completed adjustment. Void and create a new compensating adjustment." |
| SK-PERM-04 | Store Keeper attempts to post to a location outside tb_user_location scope |
Deny — scope. Picker filters to user's scope; direct API submission with out-of-scope location_id returns "You do not have permission to post to location <LOC-X>." ADJ_VAL_003 runs at submit; scope check at gateway both layers block. |
| SK-PERM-05 | Store Keeper attempts to post to a direct-cost or inactive location | Deny per ADJ_VAL_003 / ADJ_POST_007. Direct-cost locations rejected; inactive locations filtered from picker; direct API returns the validation error. |
| SK-PERM-06 | SoD violation — Store Keeper writes off own receipt above threshold | Deny per ADJ_AUTH_010. When tb_stock_out.created_by_id matches tb_good_received_note.created_by_id of the lot's originating GRN and cost above SoD threshold, submit returns "You created the receipt for this lot; an independent adjuster must initiate the write-off (SoD)." Routine small write-offs exempt. Maps to parent Scenario 14. |
| SK-PERM-07 | Store Keeper attempts to post into closed or locked period |
Deny per ADJ_VAL_011 / inventory INV_VAL_008. Returns "Cannot post into period <YYMM>: period is <closed/locked>." Document stays draft. Store Keeper changes date or escalates to Finance Manager re-open (closed only). Maps to parent Scenario 10. |
| SK-PERM-08 | Store Keeper attempts to use a reason of wrong direction | Deny per ADJ_VAL_002. A tb_stock_in document with a stock_out-direction reason (or vice versa) is rejected: "Adjustment reason is required and must match the document direction." Picker normally filters; direct-API call re-checked. Maps to parent Scenario 15. |
| SK-PERM-09 | Store Keeper attempts to void a completed document directly |
Deny — must use compensating reversal. Per ADJ_POST_004, void is a Controller / Finance action and requires a compensating reversal first. SK API call returns "Voiding a completed adjustment requires the Inventory Controller or Finance role and proceeds via a compensating reversal." |
| # | Scenario | Trigger | Expected error |
|---|---|---|---|
| SK-VAL-01 | Document number collision on auto-generation race (ADJ_VAL_001) |
Two simultaneous submits hit the same si_no after the 3x retry. |
Submit fails with "Stock-in number <si_no> already exists for this tenant." UI reloads with a fresh si_no via the numbering service. |
| SK-VAL-02 | Missing reason code or direction mismatch (ADJ_VAL_002) |
adjustment_type_id = null or tb_adjustment_type.type mismatches the document direction. |
Reject at save / submit with "Adjustment reason is required and must match the document direction." |
| SK-VAL-03 | Missing or inactive location (ADJ_VAL_003) |
location_id = null or referenced location has is_active = false / deleted_at set / location_type = direct. |
Reject with "Location is required and must be an inventory- or consignment-type location." (null/inactive); "Direct-cost locations cannot be the target of an adjustment." (direct-type). |
| SK-VAL-04 | Empty description at submit (ADJ_VAL_004) |
Description field left blank when submitting. | Reject at submit with "Description is required for audit purposes." (Soft-fail at save with warning.) |
| SK-VAL-05 | Department / cost-centre missing in dimension (ADJ_VAL_005) |
dimension JSON empty or no department entry; tenant policy requires it. |
Reject at submit with "Department / cost-centre is required (set via dimension)." |
| SK-VAL-06 | Inactive product or product not enabled at location (ADJ_VAL_006) |
product_id references an inactive tb_product or no tb_product_location row exists. |
Reject with "Product <product_code> is not active or not enabled at location <location_code>." |
| SK-VAL-07 | Zero or negative quantity (ADJ_VAL_007) |
Line qty = 0 or qty < 0. |
Reject with "Quantity must be greater than zero on every line." |
| SK-VAL-08 | Negative cost (ADJ_VAL_008) |
Line cost_per_unit < 0 (typically client-side validation bypass attempt). |
Reject with "Cost per unit must be non-negative." |
| SK-VAL-09 | Lot identity collision or missing expiry (ADJ_VAL_009) |
Stock-in new-lot collision with existing (product, location, lot_no); or stock-out from non-existent lot; or perishable new-lot without expiry. |
Reject with the matching error message ("Lot <lot_no> already exists..." / "Lot <lot_no>... is not available for consumption." / "Expiry date is required for perishable product <product> on new lot <lot_no>."). |
| SK-VAL-10 | Required-document missing (ADJ_VAL_010) |
Reason flagged info.requiresDocument = true; no comment with non-empty attachments JSON. |
Reject at submit with "Supporting document attachment is required for this adjustment reason." Maps to parent Scenario 16. |
| SK-VAL-11 | Backdated date into closed period (ADJ_VAL_011) |
si_date / so_date in a closed period. |
Reject with "Cannot post into period <YYMM>: period is closed." Maps to parent Scenario 10. |
| SK-VAL-12 | Negative-balance on stock-out (ADJ_VAL_012) |
Stock-out qty > available at picked lot. |
Reject with "Outbound movement would drive on-hand at (LOC-A, P-1, LOT-1) below zero. Available: 5, requested: 7." Document stays draft. SK reduces qty / picks different lot / escalates. Maps to parent Scenario 9. |
| SK-VAL-13 | Edit attempt on completed document (ADJ_VAL_013) |
PATCH to a tb_stock_in.id where doc_status = completed. |
Reject with "Cannot edit a completed adjustment. Void and create a new compensating adjustment." |
| # | Scenario | Condition | Expected |
|---|---|---|---|
| SK-EDGE-01 | Threshold boundary inclusive | Aggregate cost ฿500.00 exactly (auto-approve threshold). |
Boundary inclusive — auto-approve. Document advances draft → completed without Controller handoff. ฿500.01 exceeds and routes to Controller. |
| SK-EDGE-02 | Decimal precision on qty / cost | Line qty = 5.12345 (5dp); cost_per_unit = ฿11.33333. |
Stored at 5dp; total_cost = ฿58.04148... rounded display 2dp = ฿58.04. Mirrors ADJ_CALC_011 / inventory INV_CALC_012. |
| SK-EDGE-03 | Concurrent posts to same lot | Two SKs submit stock-in to (LOC-A, P-1, LOT-1) qty 5 at same time; both below threshold. |
Both post — append-only inserts; cost-layer rows ordered by lot_seq_no / created_at; on-hand advances by 10; WA recomputed sequentially per ADJ_CALC_005. |
| SK-EDGE-04 | Multi-line aggregate above threshold but per-line below | 6 lines × ฿100.00 each; aggregate ฿600.00. |
Routes for Controller approval based on aggregate. Threshold check sums across lines; whole document routes to Controller per 03-user-flow-store-keeper.md Section 3 decision branch. |
| SK-EDGE-05 | Requires-quality-check bypass auto-approve | Reason has info.requiresQualityCheck = true; cost below threshold. |
Routes to Controller despite below-threshold. Auto-approve fast path bypassed per 03-user-flow.md Section 2.2. Maps to parent Scenario 17. |
| SK-EDGE-06 | Period rollover during draft | SK starts draft 2026-04-30 23:50, submits 2026-05-01 00:05; period 2026-04 auto-closed overnight. |
Transaction-date drives period. If draft has si_date = 2026-04-30, submit rejected per ADJ_VAL_011. If 2026-05-01, submit succeeds against 2026-05 period. Default si_date = submit_time. |
| SK-EDGE-07 | FOC stock-in dilutes WA | WA product with on-hand 100 at ฿11.33; FOC stock-in qty = 10, cost = 0 (new lot, requires Controller approval). |
After Controller approval: new average = (100 × 11.33 + 10 × 0) / 110 = ฿10.30. The FOC qty dilutes the average per ADJ_CALC_005 / inventory INV_CALC_007. |
| SK-EDGE-08 | Stock-out with cost outlier flag | FIFO pick produces cost_per_unit = ฿42.50 but recent vendor pricelist average is ฿15.00; cost above threshold. |
Auto-approve does not fire (above threshold); routes to Controller; the outlier-cost flag prompts Controller investigation per 03-user-flow-inventory-controller.md Section 3 decision branches. |
| SK-EDGE-09 | Consignment location stock-in | Stock-in to location_type = consignment (vendor-owned stock); below threshold; reason FOUND_STOCK. |
Auto-approves per threshold; tb_inventory_transaction written; consignment-flagged cost-layer row; no Inventory asset debit, no AP credit per ADJ_POST_008 / inventory INV_POST_004. Maps to parent Scenario 12. |
ADJ_VAL_001 (doc number unique), ADJ_VAL_002 (reason direction), ADJ_VAL_003 (location), ADJ_VAL_004 (description), ADJ_VAL_005 (department), ADJ_VAL_006 (product), ADJ_VAL_007 (qty > 0), ADJ_VAL_008 (non-negative cost), ADJ_VAL_009 (lot identity), ADJ_VAL_010 (attachment), ADJ_VAL_011 (period), ADJ_VAL_012 (no negative balance), ADJ_VAL_013 (immutable completed), ADJ_AUTH_001 (SK create), ADJ_AUTH_002 (SK auto-approve), ADJ_AUTH_003 (new-lot Controller), ADJ_AUTH_010 (SoD), ADJ_POST_001 (submit), ADJ_POST_002 (post fan-out), ADJ_CALC_005 (WA refresh), ADJ_CALC_006 (FIFO outbound), ADJ_CALC_011 (rounding).../carmen-inventory-frontend-e2e/tests/720-stock-issue.spec.ts — stock-out path Store-Keeper-relevant; 501-grn.spec.ts Stock Movements describe block — inbound posting pattern that adjustment stock-in shares. Happy-path fixture aligns with purchase@blueledgers.com (multi-role); permission-denial uses requestor@blueledgers.com.tb_stock_in / tb_stock_out documents that the SK does not own directly.INV_VAL_005 negative-balance, INV_VAL_008 period gate, INV_CALC_005 / INV_CALC_006 cost picks).