# Inventory (stock and locations): the module source.
#
# PROVENANCE. Written 2026-10-22 for milestone M4 (Plan to Inventory, erp-roadmap #30; deliveries and receipts
# #31 and valuation into the ledger #32 are seams of this spec), conveyor steps 1-2 (spec + BPMN), from:
# - the pattern (pattern.md) and the shared vocabulary (shared.yaml, including conventions:);
# - the four signed M1 specs (tax, sales-order, ar, payments) for the house style, and the built bricks for the
# routes this module calls: erp-brick-sales-order record_delivery (POST /sales-orders/{sales_order_id}/deliveries),
# erp-brick-product convert / mark_unit_used / gtin, erp-brick-ar's ledger adapter (POST /journal-entry/journals);
# - the end-to-end process and customization level of a mainstream SME suite's Inventory app (warehouses,
# locations, receipts, deliveries, internal transfers, physical counts, lots and serials, average cost) as a
# parity reference only: the data model is our own, and kept small.
# NOT YET SIGNED by the customer. The decisions an implementer must not undo:
# - stock is a LEDGER: every quantity that moves is an append-only move from one location to another; on hand is the
# sum of moves, derived on read, and never a stored quantity somebody edits. A mistake is a reversing move
# - every quantity in the ledger is in the product's BASE unit, converted exactly (numerator / denominator from the
# product module); the unit a document was written in is kept beside it
# - two writers can never both take the last unit: every move out of an internal location, and every reservation,
# claims a row with a plain INSERT on a key the other writer would also need (shared.yaml: 23505 is not an upsert)
# - stock value is average cost per product per company, appended per move; the value in the books is the sum of the
# moves' values, so a product at zero quantity is at zero value
# - inventory asks the ledger to post; it never writes gl_ tables, and it never writes an order's deliveries itself:
# it calls the sales order's own route
# - a receipt from a supplier is recorded in ONE place: purchasing's POST /purchase-orders/{purchase_order_id}/receipts
# (this module's goods-in scanner calls it). Purchasing then posts each line here through POST /receipts, keyed
# move_ref "pur:<receipt_id>:<line_no>", and a return to the supplier through POST /returns-to-supplier (wave 2)
#
# VERSION 2 (2026-10-22, wave 2, DRAFT; "up to SAP standard" for units of measure, shared.yaml units.uom):
# - the product's WAREHOUSE unit is the unit of handling and counting: a count records any of the product's units
# (5 PK24 and 6 bottles), stock is shown in the warehouse unit first, a pick list proposes in it (INV-34)
# - receipts and deliveries copy each unit's weights and volume from product when a line is written, and answer
# the document's gross and net weight and volume for shipping (INV-35)
# - stock status by location: a quality-inspection or blocked location holds stock that is on hand and valued but
# never available, reserved or picked; releasing it is a transfer (INV-36, SAP stock types; closed in this wave)
# - a receipt is valued from purchasing's price per pricing quantity of its pricing unit (purchasing v3)
# - CATCH WEIGHT and BATCH-SPECIFIC units are designed now and DEFERRED to M7 (`deferred:`), their columns
# reserved and NULL; `sap_parity:` compares this module with SAP IM / WM
#
# VERSION 3 (2026-10-22, SAP full-scope pass, DRAFT for sign-off: every inventory process re-signs; goods_issue,
# replenishment and standard_price are new processes):
# - goods issue to a cost centre and scrapping, in the product's issue unit, with a second person above a threshold
# (INV-37, INV-38); FEFO / FIFO lot selection with minimum shelf life (INV-39); serials end to end and lot recall
# (INV-40, INV-41); ABC classes and cycle counting (INV-42, INV-43)
# - STOCK TYPE is part of every stock row (product, location, lot, stock type, owner); a location's status is its
# default and its limit, so a pallet can be put in quality inspection where it stands (INV-55; v2 had it by
# location only)
# - special stocks: supplier consignment (owner on the stock row, withdrawal settled by purchasing), customer
# consignment and subcontractor stock (our stock at their location) (INV-45 to INV-47); sales-order and project
# stock wait for M7 / M5
# - ATP by date served to sales-order (INV-49); putaway rules and bin capacity, pick batches in walking order and
# packing into SSCC-numbered packages (INV-50 to INV-52); transit between sites with an expected arrival (INV-53)
# - replenishment from product's site data (reorder point, min / max, lot size, rounding profile) into purchasing's
# purchase requests or our own transfers (INV-44, INV-54)
# - standard cost as an alternative to average, chosen per company by the owner, with price differences and
# revaluation (INV-48, INV-56)
# - full EWM (warehouse tasks and orders, RF queues, slotting, labour, yard) stays deferred; catch weight and
# batch-specific units stay deferred to M7 (`deferred:`, reasons in `sap_parity:`)
# Version 7 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 6 stays the signed one until it is signed):
# - seams only, no process change: GET /availability and GET /valuation/products listed for manufacturing; GET
# /warehouses/{warehouse_id} and GET /locations for pos; the combined row 'GET /serials/... and GET
# /lots/.../trace' (with crm) split into one row per route, and the lot trace listed for manufacturing (MFG-16)
# - PROCESS change (goods_issue): issue_from_source takes source_kind production_output_reversal, naming
# reverses_move_ref, so an output or by-product received in error goes back out at the value it came in (INV-58;
# a new seam row for manufacturing). No step is renamed or removed
#
# Version 6 (2026-10-25, DRAFT for sign-off; the fixes the bricks and the end-to-end wiring found, and wave 3's routes):
# - PROCESS (goods_issue): issue_from_source, a goods issue another module asks for (pos sales and recipes,
# production issues): POST /goods-issues/from-source, idempotent on move_ref, answering each move's value
# - PROCESS (receipt): receive_from_source, a receipt another module asks for (production output, by-products,
# restocked till refunds) through POST /receipts with input lots for the trace
# - PROCESS (standard_price): intake_standard_price, a released cost roll-up becomes dated standard prices (POST
# /standard-prices), revalued by INV-48
# - PROCESS (replenishment_proposal): projected availability counts manufacturing's dependent requirements; a
# product planned make or mrp is manufacturing's and is not proposed here
# - POST /valuation-adjustments takes source_kind production_settlement (an average-cost company's production
# variance)
# - use cases (MCP tools) of the shared families name the module noun (tool_names)
#
id: inventory
name: Stock & warehouses
short: Inventory
version: 7
maturity: tested
summary: >-
Where every item is and how many you have: warehouses and their shelves, goods received from suppliers, picked
and delivered to customers, moved between shelves and sites, and counted, with every movement recorded so the
quantity on a shelf and the value of the stock always add up, and nobody sells the last unit twice.
decisions: # nothing signed yet; customer_varies below is the Phase 2 agenda
- proposed: "v2: stock status is a property of the location (unrestricted, quality, blocked), not of each quant; releasing stock is a transfer between locations"
status: "superseded by v3 (below)"
- proposed: "v3: stock type is part of every stock row (product, location, lot, stock type, owner); a location's status is the default type of what is put there and the only types it accepts (a QC area takes quality only, a normal bin all three); a change of type where the stock stands is a move between types with no value change. Why: a small warehouse cannot always carry a doubtful pallet to a QC area, and SAP's 321 / 343 / 349 change the type without a movement"
status: "for sign-off (SAP full-scope pass)"
- proposed: "v3: valuation is average cost or standard cost, chosen per company by the owner from the first day of a later month; standard prices are dated rows per company and product; a change revalues stock on hand"
status: "for sign-off (SAP full-scope pass)"
- proposed: "v3: replenishment is a rule (reorder point, min / max from product's site data), so it may send purchase requests on its own when the owner sets auto; the default is suggest"
status: "for sign-off (SAP full-scope pass)"
- proposed: "v3: full EWM (warehouse tasks, RF queues, slotting, labour management, yard) is deferred: putaway rules, pick batches, packing and SSCCs cover a warehouse of an SME to mid-market firm"
status: "for sign-off (SAP full-scope pass)"
- proposed: "v2: catch weight and batch-specific units are designed with their columns reserved and stay off until M7"
status: "for sign-off (wave 2)"
facets:
domain: supply-chain
industries: [any]
customer_words:
- "how many do we have in stock"
- "which shelf is it on"
- "we sold something we didn't have"
- "stock take"
- "goods in, goods out"
- "what is our stock worth"
- "batch numbers"
- "serial numbers"
- "first expiry first out"
- "cycle count"
- "reorder point"
- "stock at the customer"
- "consignment stock from the supplier"
- "standard cost"
- "scrap"
replaces: [a stock spreadsheet, the stock tab in the accounting package, Odoo Inventory]
pillars: [sql]
first_module_fit: medium
depends_on:
- module: product
why: every stock row names a product (SKU) and counts it in the product's base unit, converted exactly by the product module
required: true
- module: sales-order
why: deliveries are made against confirmed orders, reserve their lines, and record what left on the order
required: recommended
- module: purchasing
why: receipts are expected from purchase orders and carry their cost
required: recommended
- module: gl
why: stock value and its movements post to the ledger; without it the valuation is exported for the accountant
required: optional
- module: business-partner
why: a receipt names its supplier and a delivery its customer, by bp_id
required: recommended
feeds: [sales-order, purchasing, gl]
owns_master_data: []
references_master_data: [product, business_partner]
actors:
- id: warehouse_manager
name: Whoever runs the warehouse (operations lead, the owner in a small firm)
does: sets up warehouses, zones and bins, putaway rules and pick sequence, decides which products are tracked by lot or serial and how lots are picked, plans transfers, batches deliveries, starts counts (from the cycle count plan) and applies small adjustments, changes stock types and blocks lots, accepts replenishment proposals, reserves stock by hand for a priority order
sees: every warehouse, location, document and move; quantities and the value of a count's difference
permissions: [inv:stock:read, inv:warehouse:write, inv:product:write, inv:receipt:validate, inv:delivery:validate, inv:transfer:write, inv:count:write, inv:adjustment:apply, inv:reservation:write, inv:move:reverse, inv:issue:write, inv:stock_type:write, inv:replenishment:write, inv:settings:write]
- id: stock_operator
name: The person on the floor (picker, packer, goods-in)
does: receives goods and returns, inspects returns, puts away, picks, packs and ships deliveries, moves stock between bins, issues stock to a cost centre or scraps it, records what a count found
sees: products, locations, quantities and the documents of their warehouse; never a cost or a value
permissions: [inv:stock:read, inv:receipt:validate, inv:delivery:validate, inv:transfer:write, inv:count:write, inv:issue:write]
- id: stock_controller
name: A second person for large differences (finance controller, the owner)
does: approves a count adjustment, a goods issue or a scrap whose value is over the threshold, and a transit shortfall written off; never one they counted, submitted or requested
sees: every count, issue and scrap, its difference and its value
permissions: [inv:stock:read, inv:valuation:read, inv:adjustment:approve, inv:issue:approve]
- id: owner
name: The owner
does: sets the controls on the warehouse (negative stock, the approval thresholds, lots and serials, the ledger mode, the valuation method per company, whether replenishment sends on its own, whether expired stock may be picked, the AI step's mode)
sees: everything
permissions: [inv:stock:read, inv:valuation:read, inv:settings:controls]
- id: accountant
name: Finance (bookkeeper, outside accountant)
does: reads stock value per product and company, the average or standard cost, and the journals posted for each movement; keeps the account map and the standard prices
sees: quantities, values and postings; changes no stock
permissions: [inv:stock:read, inv:valuation:read, inv:account_map:write, inv:valuation:write]
- id: sales_rep
name: Sales
does: asks what is available before promising a date
sees: available quantities per product and warehouse; never a cost, a value or a bin
permissions: [inv:availability:read]
- id: system
name: The system
does: posts the stock move of each receipt line and return purchasing records, takes reservations from sales orders, expects returns from them, answers ATP, derives on hand and availability, appends average or standard cost, posts valuation to the ledger, claims document numbers, classifies ABC and proposes replenishment, suggests a cause for a count difference, imports the Odoo opening stock once
sees: everything, through its own service account
permissions: [inv:stock:read, inv:availability:read, inv:receipt:intake, inv:reservation:intake, inv:return:expect, inv:valuation:post, inv:number_series:claim, inv:count:suggest, inv:replenishment:propose, inv:abc:classify, inv:migration:import, inv:migration:read, inv:issue:intake, inv:standard_price:intake]
stories:
# ── person ────────────────────────────────────────────────────────────────────────────────
- id: INV-01
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want my sites, zones and bins as a tree, so that every item has an address and a count can cover one aisle.
given: no warehouse yet; company UK1
when: I add warehouse WH1 "Leeds" for company UK1, zone WH1/A and bins WH1/A/01 to WH1/A/40
then: "WH1 exists with its stock root WH1 (kind view) and its locations; WH1/A/01 is kind internal, its parent WH1/A; adding a second WH1/A/01 is refused (\"WH1/A/01 already exists\"); a location's code is fixed once a move names it; the virtual locations SUPPLIER, CUSTOMER, LOSS, TRANSIT and OPENING exist once per company, are not stock (on hand there is never shown as available) and cannot be renamed, archived or counted"
screen: Warehouses
path: { process: inventory.warehouse, choices: {} }
reference: not built
customer_varies: how many levels (warehouse, zone, bin, or just warehouse); whether bins are used at all
- id: INV-02
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want to archive a bin we no longer use, so that nobody puts stock there, without losing its history.
given: bin WH1/A/40 with 3 units of COLA-075, and bin WH1/A/39 empty
when: I archive both
then: 'WH1/A/39 is archived and no new move may name it; WH1/A/40 is refused ("WH1/A/40 holds 3 C62 of COLA-075: move it first"); every past move naming either still reads the same'
screen: Warehouses
reference: not built
- id: INV-03
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want to say which products we keep in stock and which are tracked by batch or serial number, so that we only count what we hold and can trace a recall.
given: products COLA-075 (kind goods), HOST-M (kind service) and DRILL-500 (goods); lots and serials turned on by the owner
when: I set DRILL-500 to tracking serial and COLA-075 to tracking lot
then: "each is recorded from today; HOST-M is refused (\"HOST-M is a service: services are not stocked\"); changing the tracking of a product with stock on hand is refused, naming the quantity and where it is; with lots and serials off (the default) the tracking choice is not offered"
screen: Stock
reference: "not built: the product brick has kind goods | service and no tracking field, so tracking is inventory's own row per product"
customer_varies: whether any product is tracked at all
- id: INV-04
kind: person
actor: stock_operator
story: As someone on the floor, I want to see how many of a product are where, and how many are already promised, so that I pick from the right bin and do not promise stock twice.
given: "COLA-075, base unit LTR: WH1/A/01 holds 36 LTR, WH1/A/02 holds 9 LTR; a confirmed order reserves 18 LTR at WH1"
when: I look COLA-075 up, or scan its pack GTIN
then: "on hand 45 LTR at WH1 (by bin: 36 and 9), reserved 18, available 27; each shown also in the product's sales unit (60 bottles on hand) by the product's exact conversion; a sales rep asking sees only available 27 per warehouse, no bins and no value; the answer is derived from the moves at the moment it is asked"
screen: Stock
path: { process: inventory.stock, choices: {} }
reference: not built
- id: INV-05
kind: person
actor: stock_operator
story: As goods-in, I want to receive what a supplier delivered against what we expected, so that stock, the purchase order and the books agree.
given: "purchase order PO-0101 line 1 still expects 10 PK24 of COLA-075 (PK24 = 24 bottles = 18 LTR) at 21.60 a pack, as purchasing's GET /purchase-orders/PO-0101/expected answers; tolerance 0%"
when: I scan 8 PK24 into WH1/A/01 against delivery note DN-881 and submit
then: "the scanner records the receipt through purchasing (POST /purchase-orders/PO-0101/receipts, INV-21); purchasing posts the line back (POST /receipts, move_ref pur:<receipt_id>:1, INV-20) and a done receipt numbered WH1/IN/00001 holds one move SUPPLIER -> WH1/A/01 of 144 LTR (8 PK24, numerator 18 / denominator 1, kept beside it), valued 172.80; purchasing shows 2 PK24 outstanding on line 1; scanning 11 PK24 is refused before purchasing is called (\"11 is more than the 10 expected: the tolerance is 0%\"); submitting DN-881 twice records one receipt; the receipt's posting date is today or earlier and falls in an open period, never in the future (a future date is refused 422 with the reason, as is a date in a closed period; the default is today)"
screen: Receipts
path: { process: inventory.receipt, choices: {} }
reference: not built
customer_varies: the over-receipt tolerance; whether a receipt is one step (into a bin) or two (goods-in, then put away)
- id: INV-06
kind: person
actor: stock_operator
story: As goods-in, I want to record the batch or serial numbers as I receive tracked products, so that a recall can name the customers who got them.
given: DRILL-500 tracked by serial; COLA-075 tracked by lot; an order still expecting 2 DRILL-500 and 1 PK24 of COLA-075
when: I scan serials SN-1 and SN-2 and lot L-2611 and submit
then: "two moves of 1 C62 each, one per serial, and one move of 18 LTR with lot L-2611; submitting without a serial on each unit is refused (before purchasing is called) (\"2 DRILL-500 need 2 serial numbers, 1 given\"); a serial already in stock is refused (\"SN-1 is already on hand at WH1/A/03\")"
screen: Receipts
reference: not built
- id: INV-07
kind: person
actor: stock_operator
story: As a picker, I want to pick and ship what a confirmed order needs, so that the customer gets it and the order knows it was delivered.
given: "sales order SO-2026-0012 line 1: 48 C62 (bottles) of COLA-075 (0.75 LTR each) reserved at WH1; WH1/A/01 holds 36 LTR"
when: I create the delivery from the order, pick 24 bottles (18 LTR) from WH1/A/01 and validate
then: "a done delivery WH1/OUT/00001 with a move WH1/A/01 -> CUSTOMER of 18 LTR (24 C62) valued at the average cost; the reservation drops by 18 LTR; the sales order records a delivery of 24 on line 1 with delivery_ref inv:WH1/OUT/00001; the remaining 24 stay on a back-order delivery for the same order; picking from a bin that does not hold it is refused naming what the bin holds"
screen: Deliveries
path: { process: inventory.delivery, choices: {} }
reference: "not built: sales-order's record_delivery route exists and refuses more than ordered, a future date and a date before confirmation"
- id: INV-08
kind: person
actor: stock_operator
story: As a picker, I want the system to stop two of us shipping the last unit, so that we never deliver what we no longer have.
given: "WH1/A/03 holds one DRILL-500; two deliveries for two orders name it, neither reserved (one order came in by phone); negative stock is not allowed"
when: two pickers validate at the same moment
then: "exactly one succeeds; the other is refused (\"WH1/A/03 holds 0 DRILL-500 now: another delivery took it\") and nothing of it is written, neither the move nor the call to the sales order; on hand never shows -1"
screen: Deliveries
reference: not built
- id: INV-09
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want to move stock between bins or between sites, so that the system says where things really are.
given: WH1/A/01 holds 36 LTR of COLA-075; a second warehouse WH2 of the same company
when: I move 18 LTR to WH1/B/07, and ship 9 LTR to WH2
then: "the first is one done transfer WH1/INT/00001 (WH1/A/01 -> WH1/B/07), no value changes; the second is a transfer in two steps: shipped WH1/A/01 -> TRANSIT now, received TRANSIT -> WH2/A/01 when it arrives, the 9 LTR counted in transit until then and in neither warehouse's available; a transfer to a warehouse of another company is refused (\"WH9 belongs to company DE1: an inter-company move is a sale and a purchase\")"
screen: Transfers
path: { process: inventory.transfer, choices: {} }
reference: not built
- id: INV-10
kind: person
actor: stock_operator
story: As whoever counts, I want to record what is actually on the shelves, so that the system matches reality.
given: a count started by the warehouse manager on WH1/A at 08:00; the moves say WH1/A/01 holds 36 LTR of COLA-075; at 09:00 a picker ships 9 LTR from WH1/A/01
when: at 10:00 I record 24 LTR counted at WH1/A/01, and submit the count
then: "the difference is computed against what the moves said at the moment the line was counted (27 LTR), not at the start: -3 LTR, valued at the average cost; nothing changes on hand until the count is applied; counting a product into a bin it was never moved to is allowed (a found item); a counted quantity above 10^12 or with more decimals than the unit allows is refused with the reason (422), never an error"
screen: Counts
path: { process: inventory.stock_count, choices: {} }
reference: not built
- id: INV-11
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want small count differences applied at once and large ones checked by a second person, so that shrinkage is visible and nobody writes off stock alone.
given: approval threshold 500.00 GBP (the sum of the absolute value differences across the lines) and a per-line limit 200.00 GBP; count C-14 with two lines worth -42.10 and +30.00; count C-15 worth -1,250.00; count C-16 with three lines worth -250.00, -20.00 and +20.00
when: I apply C-14, and submit C-15 and C-16
then: "C-14 applies (sum 72.10, no line at 200.00); C-16 waits for approval although its sum 290.00 is under 500.00, because one line is at or above the per-line limit; one adjustment move per line, between the bin and LOSS, and the loss posted to the ledger; C-15 waits for approval; a stock controller who is neither the counter nor the submitter (compared by email AND by sign-in subject, so a second account of the same person is refused) approves it and it applies; the submitter approving is refused (\"you submitted C-15: a second person approves\")"
screen: Counts
path: { process: inventory.stock_count, choices: { Gateway_count_value: over_threshold } }
reference: not built
customer_varies: the threshold, and whether every count needs a second person
- id: INV-12
kind: person
actor: stock_controller
story: As the second person, I want to see what a count found and what it is worth before I approve it, so that I approve a loss knowingly.
given: count C-15 submitted with 6 lines, value -1,250.00, and the AI's suggested causes for two of them
when: I open it
then: "each line shows expected (at its counted moment), counted, difference and value, and any suggestion marked as a suggestion; I approve, or reject with a reason (the count returns to the warehouse manager to recount); I cannot change a counted quantity"
screen: Counts
reference: not built
- id: INV-13
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want to hold stock for an important order, or release it, so that the right customer gets the last units.
given: 27 LTR of COLA-075 available at WH1; order SO-2026-0014 asks for 36 LTR and is reserved 27, short 9
when: I release 9 LTR from SO-2026-0012 and reserve them for SO-2026-0014
then: "SO-2026-0014 is fully reserved; SO-2026-0012 is short 9 and shows it; both changes are appended with who and why; reserving more than available is refused, naming what is available"
screen: Deliveries
reference: not built
- id: INV-14
kind: person
actor: stock_operator
story: As goods-in, I want to take back what a customer returns against its RMA and inspect it, so that the stock, the return order and the credit follow what actually came back.
given: "delivery WH1/OUT/00001 of 24 bottles of COLA-075 on SO-2026-0012 line 1, valued 21.60; sales-order authorised RMA RE-2026-0001 for 6 bottles (POST /expected-returns, v3)"
when: "the customer returns 6 bottles; I receive them, inspect them (4 good, 1 damaged by us, 1 damaged by the customer) and submit; another parcel arrives with no RMA"
then: "a return receipt WH1/RET/00001 with a move CUSTOMER -> WH1/QC of 4.5 LTR in stock type quality, valued at the cost those bottles left at (5.40), not today's average; the returns order records 6 received (record_delivery on RE-2026-0001 with source inventory, delivery_ref inv:WH1/RET/00001); the inspection moves 3 LTR to WH1/A/01 unrestricted (no value change), scraps 0.75 LTR (INV-38) and sends 0.75 LTR back to the customer at the value it came in, and reports 5 accepted and 1 rejected to sales-order (POST /sales-orders/RE-2026-0001/return-inspections, once); the parcel with no open RMA is refused (\"no open RMA of 7311 names COLA-075\"); receiving more than the RMA expects, or a serial never delivered to that customer, is refused"
screen: Receipts
reference: "not built: v3 receives against sales-order v5's returns order (RMA); the v2 negative correction on the original order stays only for a hand-recorded delivery mistake before invoicing"
- id: INV-15
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want to correct a move recorded by mistake without editing it, so that the history stays true.
given: transfer WH1/INT/00003 moved 18 LTR to WH1/B/07 instead of WH1/B/08
when: I reverse it with reason "wrong bin" and record the right transfer
then: "a reversing move WH1/B/07 -> WH1/A/01 of 18 LTR names the original; the original is unchanged and marked reversed; reversing it twice is refused; a delivery or receipt is never reversed this way: a delivery is undone by a return (INV-14), a receipt by a return to the supplier (known gap)"
screen: Stock
reference: not built
- id: INV-16
kind: person
actor: accountant
story: As finance, I want stock value per product and company on any date, so that the balance sheet and the stock agree.
given: "COLA-075 at company UK1: received 144 LTR at 1.2000 a litre, then 72 LTR at 1.3500, then 90 LTR delivered"
when: I ask the valuation for today and for the day before the second receipt
then: "today 126 LTR, average 1.2500, value 157.50 (216 LTR worth 270.00, less 90 LTR at 1.2500 = 112.50); the earlier day 144 LTR, 172.80; each figure lists the moves and journals behind it; the total equals the sum of the moves' values exactly, and the stock account's balance in the ledger is shown beside it with any difference named"
screen: Valuation
path: { process: inventory.valuation, choices: {} }
reference: not built
- id: INV-17
kind: person
actor: owner
story: As the owner, I want to decide whether stock may go negative, so that a shipment is never blocked by a late receipt if I accept that risk, and never goes out unseen if I do not.
given: negative stock not allowed (the default); WH1/A/02 holds 9 LTR
when: a picker validates 12 LTR from it; then I allow negative stock for WH2 only and a picker does the same there
then: "at WH1 refused (\"WH1/A/02 holds 9 LTR, 12 asked\"); at WH2 allowed and the bin shows -3 LTR, flagged on the Stock screen until a receipt or a count clears it; the change is recorded from today with who set it; only inv:settings:controls changes it"
screen: Settings
reference: not built
- id: INV-34
kind: person
actor: stock_operator
story: As whoever counts and picks, I want to work in the packs we actually handle, so that I count five cases and six loose bottles instead of working out litres.
given: "COLA-075, base LTR, warehouse unit PK24 (= 18 LTR), sales unit C62 (= 0.75 LTR); WH1/A/01 holds 94.5 LTR; an order line of 48 C62 is reserved"
when: I look the bin up, count it as 5 PK24 plus 6 C62, and open the pick list for the order
then: "the bin shows 5 PK24 and 6 C62 (94.5 LTR): the warehouse unit first, the rest in the sales unit, and in the base unit where neither divides it exactly; the count line holds two entries (5 PK24, 6 C62), each with its unit's conversion, and counted 94.5 LTR; the pick list proposes 2 PK24 for the 48 C62; an entry in a unit the product does not have, or with more decimals than the unit allows, is refused with the reason; with no warehouse unit set the base unit is used and said so"
screen: Counts
path: { process: inventory.stock_count, choices: {} }
reference: "SAP WM unit of measure (MLGN-LVSME) and counting in alternative units (MI04 entry in any unit of entry); Odoo counts in the product's unit only"
customer_varies: which unit each product is handled in (case, layer, pallet)
- id: INV-35
kind: person
actor: stock_operator
story: As whoever ships and receives, I want each delivery and receipt to show its weight and volume, so that I can book the carrier and check a delivery against the supplier's note.
given: "PK24 of COLA-075 at gross 19.2 / net 18.0 KGM and 0.027 MTQ; the bottle at gross 0.79 / net 0.75 KGM with no volume (product PRD-28)"
when: a delivery of 3 PK24 and 6 C62 is created, and a receipt of 8 PK24 is posted
then: "each line copies its unit's measures when it is written; GET /documents/{document_id}/shipping answers the delivery's gross 62.34 KGM, net 58.5 KGM and volume 0.081 MTQ with \"volume unknown for 1 of 2 lines\", and the receipt's gross 153.6 KGM; measures in other units are converted through unit_conversion (GRM, LBR) exactly; a measure changed on the product later (a new unit, PRD-34) never changes a document already written; with delivery_needs_weights on, validating a delivery with a line of unknown weight is refused naming the line"
screen: Deliveries
path: { process: inventory.delivery, choices: {} }
reference: "SAP delivery header gross / net weight and volume (LIKP-BTGEW, NTGEW, VOLUM) from MARM per item; Odoo delivery weight from product weight"
customer_varies: whether weights must be known before a delivery leaves (pallet freight, parcel carriers)
- id: INV-36
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want goods in quality inspection or set aside as damaged to be counted and valued but never sold, so that nobody ships stock we have not released.
given: "location WH1/QC with stock status quality and WH1/DAMAGED with status blocked; 8 PK24 of COLA-075 are received into WH1/QC"
when: an order asks availability, a picker tries WH1/QC, and I release 6 PK24 to WH1/A/01 and move 2 PK24 to WH1/DAMAGED
then: "on hand at WH1 counts the 144 LTR, available counts none of it until released; WH1/QC cannot be reserved or picked from (\"WH1/QC is in quality inspection: release it first\"); the release is a transfer WH1/QC -> WH1/A/01 of 108 LTR with no value change, and the 36 LTR at WH1/DAMAGED stay on hand, valued and unavailable until a count writes them off to LOSS or a return sends them to the supplier; changing a location's status while it holds stock is refused (move the stock first)"
screen: Warehouses
path: { process: inventory.transfer, choices: {} }
reference: "SAP stock types unrestricted / quality inspection / blocked and transfer postings between them (movement types 321, 343, 344, 349); Odoo multi-step receipts through a quality location"
customer_varies: whether goods are inspected on arrival; whether damaged goods are kept apart
note: "v3: the location's status is now the default type and the limit; the type is on each stock row (INV-55), so the same result is reached here by moving the goods, and where they stand by a type change"
# ── v3: SAP full scope (person) ────────────────────────────────────────────────────────────
- id: INV-37
kind: person
actor: stock_operator
story: As someone in the workshop or the office, I want to take stock out for our own use and charge it to a cost centre, so that consumables leave the shelf and their cost lands where it was spent.
given: "GLOVES (type RAW, stocked) base unit C62, issue unit BX100 (a box of 100, product PRD-29); WH1/A/05 holds 20 BX100; average cost 0.0800 a glove; cost centre MAINT; issue approval threshold 500.00"
when: "I issue 3 BX100 from WH1/A/05 to cost centre MAINT, then try 2 BX100 to cost centre NOPE, then the warehouse manager reverses the first with reason \"wrong cost centre\""
then: "a goods issue WH1/GI/00001 with one move WH1/A/05 -> UK1:CONSUMPTION of 300 C62 (3 BX100, the issue unit proposed and its conversion kept), valued 24.00 at the average, and a journal consumption 24.00 debit with cost centre MAINT on the line / stock_valuation 24.00 credit; NOPE is refused (gate cost_centre_valid, the ledger's answer); the reversal is a move UK1:CONSUMPTION -> WH1/A/05 of 300 C62 naming the original, valued at the original's 24.00, and a reversing journal; reversing twice is refused; an issue whose value is at or above 500.00 waits for a stock controller who is not the requester (email AND sign-in subject)"
screen: Issues
path: { process: inventory.goods_issue, choices: {} }
reference: "SAP goods issue to cost centre (MIGO / MB1A movement type 201, reversal 202), unit of issue MARC-AUSME; Odoo has no consumption document (an inventory adjustment to a virtual location)"
customer_varies: whether stock is used internally at all (workshops, maintenance, offices)
- id: INV-38
kind: person
actor: stock_operator
story: As someone on the floor, I want to scrap damaged or expired stock with a reason, so that it leaves the books as a loss we can see, not as a count difference.
given: "WH1/DAMAGED (stock type blocked) holds 36 LTR of COLA-075 lot L-2611; scrap reasons DAMAGED, EXPIRED, QUALITY"
when: "I scrap 36 LTR of L-2611 with reason DAMAGED, and then try to scrap 9 LTR more from it"
then: "a goods issue WH1/GI/00002 purpose scrap with a move WH1/DAMAGED -> UK1:LOSS of 36 LTR valued at the average, and a journal inventory_scrap debit / stock_valuation credit naming the reason; the second is refused (\"WH1/DAMAGED holds 0 LTR of L-2611\"); a scrap at or above the threshold waits for a second person; scrapping is never a count adjustment, so the loss report separates shrinkage (counts) from scrap (reasons)"
screen: Issues
path: { process: inventory.goods_issue, choices: {} }
reference: "SAP scrapping (movement type 551 from unrestricted, 553 from quality, 555 from blocked; reversal 552) with reason for movement; Odoo stock.scrap"
- id: INV-39
kind: person
actor: stock_operator
story: As a picker, I want the pick list to tell me which batch to take, first-expiring first, so that nothing expires on the shelf and no customer gets stock too close to its date.
given: "COLA-075 removal strategy FEFO at WH1; lots L-2611 (expires 2026-12-01, 18 LTR in WH1/A/01), L-2612 (expires 2027-02-01, 36 LTR in WH1/A/02), L-2601 (expired 2026-10-15, 9 LTR in WH1/A/03); 7311 requires 60 days of remaining shelf life; today 2026-11-02"
when: "a delivery of 24 C62 (18 LTR) for 7311 is picked, and a picker scans L-2611 instead of the proposed lot"
then: "the pick list proposes L-2612 from WH1/A/02 (L-2611 has only 29 days left, under 7311's 60; L-2601 is expired and is never proposed or available); scanning L-2611 is refused (gate shelf_life, \"L-2611 expires 2026-12-01: 7311 needs 60 days\"); a warehouse manager may allow it with a reason, recorded; for a product with FIFO the oldest received lot is proposed; an expired lot is excluded from availability and ATP from its expiry date and is listed for scrapping"
screen: Deliveries
path: { process: inventory.delivery, choices: {} }
reference: "SAP batch determination with sort by shelf-life expiration date (FEFO) and minimum remaining shelf life (MARA-MHDRZ, customer-specific in batch search strategies); EWM removal strategies FIFO / FEFO; Odoo removal strategies FIFO / FEFO"
customer_varies: whether stock expires; the minimum shelf life each customer accepts
- id: INV-40
kind: person
actor: stock_operator
story: As whoever handles serial-numbered goods, I want every serial followed from receipt to the customer and back, so that warranty, theft and recall questions have an answer.
given: "DRILL-500 tracked by serial; SN-1 received into WH1/A/03, SN-2 into WH1/A/04"
when: "SN-1 is delivered to 7311 on WH1/OUT/00007; 7311 returns SN-2 against RE-2026-0002; a count finds SN-1 on a shelf; a transfer is scanned without a serial"
then: "GET /serials/DRILL-500/SN-1 answers its history: received (supplier, receipt, date), moved, delivered to 7311 on WH1/OUT/00007; the return of SN-2 is refused (\"SN-2 was never delivered to 7311\"); the count line for SN-1 is refused as a found item (\"SN-1 left on WH1/OUT/00007: record a return first\"); the transfer without a serial is refused; a serial is on hand in at most one place with quantity 1, and a delivered serial is on hand nowhere"
screen: Stock
path: { process: inventory.stock, choices: {} }
reference: "SAP serial number management (serial number profile, IQ09 serial history, equipment); Odoo stock.lot of serial tracking with traceability"
- id: INV-41
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want to find every customer who received a batch, so that a recall reaches the right people the same day.
given: "lot L-2611 of COLA-075 received on R-7 from supplier 9100: 144 LTR; delivered 54 LTR to 7311 and 36 LTR to 8420; 36 LTR scrapped; 18 LTR in WH1/A/01"
when: I ask where L-2611 is and where it went, and block it
then: "GET /lots/COLA-075/L-2611/trace answers: from supplier 9100 on R-7; to 7311 (54 LTR, WH1/OUT/00003 and /00009) and 8420 (36 LTR, WH1/OUT/00005); scrapped 36; on hand 18 at WH1/A/01; nothing unaccounted; blocking it changes its stock type to blocked wherever it is (a transfer posting, no movement), so it is never reserved, picked or promised until released"
screen: Stock
path: { process: inventory.stock, choices: {} }
reference: "SAP batch where-used list (MB56) and batch status restricted / unrestricted; Odoo lot traceability report"
- id: INV-43
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want the system to tell me what to count this week, more often for what matters, so that a cycle count replaces the annual shutdown.
given: "ABC classes at WH1 (INV-42): COLA-075 A, SCREW-M4 B, GLOVES C; frequencies A 12 per year, B 4, C 1; COLA-075 last counted 2026-09-25, SCREW-M4 2026-08-01, GLOVES 2026-01-10"
when: I open the cycle count plan on 2026-11-02 and start counts from it
then: "the plan lists COLA-075 (due 2026-10-25, overdue) and SCREW-M4 (due 2026-11-01), not GLOVES (due 2027-01-10); starting from the plan makes one count per product over every bin that holds it (scope product, not a subtree); each line counts as in INV-10 at its counted moment, so nothing need stop moving; applying a count sets the product's last counted date; a count of a full subtree (the annual count) is still possible"
screen: Counts
path: { process: inventory.stock_count, choices: {} }
reference: "SAP cycle counting (MIBC: cycle counting indicator A / B / C with intervals per plant, MICN batch input), physical inventory without posting block; Odoo inventory frequency per location (annual inventory day, cyclic counting per location)"
customer_varies: the classes' thresholds and frequencies; whether an annual count is still wanted
- id: INV-44
kind: person
actor: warehouse_manager
story: As whoever replenishes, I want the system to propose what to buy or move when stock runs low, rounded to how we really order, so that we reorder on time without counting shelves.
given: "product site WH1 for COLA-075 (PRD-37): min_max, reorder point 360 LTR, max 1 440 LTR, safety stock 90, buy, purchase unit PK24 (18 LTR), rounding profile PALLET-LAYER; WH2 transfer from WH1, reorder point 72, lot size fixed 144 LTR; replenishment_mode suggest"
when: "the nightly run on 2026-11-02 finds WH1 projected 300 LTR (on hand unrestricted 360 + 0 expected - 60 reserved) and WH2 projected 54 LTR; I accept the WH1 proposal and dismiss nothing; the run is repeated"
then: "WH1: a proposal to buy up to the max, 1 140 LTR (1 440 - 300) = 63.33 PK24, 64 whole packs, rounded by GET /products/COLA-075/round-proposal to 60 PK24 (64 is 80% of a pallet, under 90%; the 4 packs over 6 layers are 40% of a layer, under 50%), so 1 080 LTR and a projected 1 380 LTR, recorded with the site row's valid_from and every figure it used; WH2: a proposal to transfer 144 LTR from WH1; accepting the first sends one purchase request to purchasing (INV-54); the WH2 proposal becomes a draft transfer; the repeat writes nothing more; in auto mode the owner's control sends them without a person (a rule, not an AI step); a proposal never sees stock in quality or blocked status or on consignment at a customer"
screen: Replenishment
path: { process: inventory.replenishment, choices: {} }
reference: "SAP consumption-based planning (MRP type VB reorder point, V1 with forecast, HB replenish to maximum; MD01 / MD03; planned order or purchase requisition; stock transport requisition for special procurement 40); Odoo reordering rules min / max and the replenishment report"
customer_varies: which products are planned; whether proposals are sent automatically
- id: INV-46
kind: person
actor: stock_operator
story: As goods-in, I want stock the supplier still owns kept apart in the books but usable on the shelf, so that we pay for it only when we take it.
given: "supplier 9100 keeps consignment stock of SCREW-M4 with us; purchasing's consignment order PO-0150 expects 5 000 C62; consignment price 2.4500 per 100"
when: "5 000 C62 are received into WH1/B/01 (purchasing posts them with special_stock supplier_consignment); a delivery picks 1 200 C62 of them; then 300 C62 are moved into our own stock"
then: "the receipt is a move SUPPLIER -> WH1/B/01 with owner 9100, valued 0.00 (not ours) and no journal; on hand shows 5 000 C62 of which 5 000 consignment of 9100; available counts them (we may use them); the delivery's validation withdraws 1 200 C62 from 9100's ownership first (a move from owner 9100 to owner us at WH1/B/01, valued 29.40 at purchasing's consignment price, journal stock_valuation debit / consignment_liability credit, and purchasing told once through POST /consignment-withdrawals so it settles with the supplier), then delivers them as our stock at the new average; the 300 C62 transfer to own stock is the same withdrawal with no delivery; returning unused consignment stock to 9100 moves it back to SUPPLIER with no value"
screen: Receipts
path: { process: inventory.receipt, choices: {} }
reference: "SAP supplier consignment (special stock K, movement types 101 K, 411 K withdrawal, consignment settlement MRKO); Odoo owner on quants (consignment)"
customer_varies: whether any supplier keeps stock with you
- id: INV-48
kind: person
actor: accountant
story: As finance, I want to value a company's stock at standard cost if the owner chooses, with the difference to what we paid shown as a variance, so that margins are stable and purchasing variances are visible.
given: "company UK1 valuation_method standard from 2026-12-01 (the owner's control, from the first day of a later month); COLA-075 standard price 1.2000 a litre from 2026-12-01; on 2026-11-30 UK1 holds 126 LTR at average 1.2500 (157.50)"
when: "the method takes effect on 2026-12-01; on 2026-12-03 144 LTR are received at 1.3500 a litre (194.40 from purchasing); on 2026-12-04 90 LTR are delivered"
then: "at 2026-12-01 a revaluation row brings 126 LTR to 151.20 (-6.30, journal stock_valuation credit / revaluation debit); the receipt is valued 172.80 at standard with a price difference of 21.60 (journal stock_valuation 172.80 debit, price_difference 21.60 debit, stock_received_not_billed 194.40 credit); the delivery is valued 108.00 (90 x 1.2000); value_after is always round_half_up(quantity_after x standard, 2) and each move's value is the change in value_after, so the moves sum exactly and zero quantity is zero value; a standard dated today or earlier, or a method switch not on the first of a later month, is refused; another company may stay on average cost"
screen: Valuation
path: { process: inventory.valuation, choices: {} }
reference: "SAP price control S (standard price) vs V (moving average) per material and valuation area (MBEW-VPRSV), price differences on goods receipt (PRD), standard price change revaluation (MR21 / CK24, UMB); IAS 2 para 21 (standard cost as a measurement technique when it approximates cost); Odoo costing methods standard / average / FIFO per category"
customer_varies: average or standard per company
- id: INV-56
kind: person
actor: accountant
story: As finance, I want to change a standard price from a date and see the stock revalued, so that next year's standard applies to what is already on the shelf.
given: "UK1 on standard; COLA-075 standard 1.2000; on hand 180 LTR on 2026-12-31"
when: "on 2026-12-20 I set 1.2500 from 2027-01-01; on 2027-01-01 the change takes effect; then I try to set a price from 2026-12-20"
then: "a revaluation row on 2027-01-01 of +9.00 (180 x 0.0500) and a journal stock_valuation debit / revaluation credit, journal_id derived (company, product, valid_from), posted once; moves from 2027-01-01 are valued at 1.2500; the back-dated price is refused (starts after today); the price per company is a dated row the accountant writes with inv:valuation:write, never product's cost_amount"
screen: Valuation
path: { process: inventory.valuation, choices: {} }
reference: "SAP standard price release (CK24 marking and release, future standard price MBEW-ZKPRS / ZKDAT)"
- id: INV-50
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want goods-in told which bin to put each item away in, so that stock goes where pickers will look and bins are not overfilled.
given: "receipt steps two (goods-in to WH1/IN, then put away); putaway rules at WH1: 1 COLA-075 fixed bin WH1/A/01 (max 108 LTR), 2 category drinks to the next bin of zone WH1/A with room, 3 everything else addition to existing stock, else zone WH1/C; WH1/A/01 holds 90 LTR"
when: "8 PK24 (144 LTR) of COLA-075 are received into WH1/IN and put away, and 10 C62 of SCREW-M4"
then: "the putaway proposes 18 LTR to WH1/A/01 (its room to 108 LTR) and 126 LTR to WH1/A/02 (the next bin of WH1/A with room by volume, from the units' copied measures); SCREW-M4 goes to the bin that already holds it; each putaway is a transfer WH1/IN -> bin with no value change; the operator may choose another bin, which is recorded with who; a bin over its capacity (quantity, weight or volume) is refused naming the capacity; stock in WH1/IN is on hand but not available until put away"
screen: Receipts
path: { process: inventory.transfer, choices: {} }
reference: "SAP WM / stock room management putaway strategies (fixed bin F, open storage C, addition to existing stock I, next empty bin L; bin capacity check); Odoo putaway rules"
customer_varies: whether bins are assigned per product (fixed) or chosen freely; one-step or two-step receipts
- id: INV-51
kind: person
actor: stock_operator
story: As a picker, I want the morning's deliveries picked together in walking order, so that I walk each aisle once instead of once per order.
given: "deliveries WH1/OUT/00011, /00012 and /00013 ready; locations carry a pick_sequence; delivery steps pick_pack_ship"
when: "the warehouse manager batches the three into pick batch WH1/PB/00001 and I pick it"
then: "one pick list over all three, sorted by pick_sequence, each line naming the lot by the removal strategy (INV-39) and the delivery it is for; picked goods move bin -> WH1/OUT-STAGE (a transfer, no value change) and are sorted to their deliveries; a delivery leaves the batch only whole; validation of each delivery still claims stock per delivery (INV-08); a delivery in a batch cannot be cancelled until it is taken out"
screen: Deliveries
path: { process: inventory.delivery, choices: {} }
reference: "SAP WM / EWM wave picking and two-step picking (pick to staging, then allocate), picking sequence by bin; Odoo batch and wave transfers"
customer_varies: whether volume justifies batching; one-step or two-step picking
- id: INV-52
kind: person
actor: stock_operator
story: As a packer, I want to pack a delivery into boxes and pallets, each with its own label number and weight, so that the carrier and the customer can check every package.
given: "WH1/OUT/00011 of 6 PK24 COLA-075 and 2 DRILL-500; package types BOX-L (tare 0.6 KGM, 60 x 40 x 40 CMT) and PALLET-EU (tare 25 KGM); our GS1 company prefix 5012345"
when: "I pack 4 PK24 on one pallet, 2 PK24 and 2 DRILL-500 in a box, and validate"
then: "two packages, each with an SSCC from the series (18 digits, check digit computed, never reused) and its contents, gross weight = tare + the contents' copied weights (PALLET 101.8 KGM, BOX the sum likewise); GET /documents/WH1/OUT/00011/shipping lists the packages; validating with an item not in any package is refused under pick_pack_ship; a package's contents never exceed what was picked"
screen: Deliveries
path: { process: inventory.delivery, choices: {} }
reference: "SAP handling units (HU, packing in the delivery, SSCC per HU, packaging materials with tare); Odoo packages (stock.quant.package) and package types"
customer_varies: whether you pack into numbered packages; pallets or parcels
- id: INV-53
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want a transfer between our sites to say when it will arrive, so that the receiving site can promise that stock and nobody loses track of a lorry.
given: "WH2 is supplied from WH1, 2 days in transit (product site PRD-37); a draft transfer of 144 LTR COLA-075 from WH1 to WH2"
when: "it ships on 2026-11-02; an order at WH2 asks ATP; it arrives on 2026-11-05 with 126 LTR"
then: "shipping records expected_arrival_on 2026-11-04 (ship date + the site's planned days); ATP at WH2 counts the 144 LTR from 2026-11-04 (INV-49); the transit stock is valued and in neither warehouse's available; on arrival 126 LTR are received and the 18 LTR short stay in UK1:TRANSIT on the transfer with gate transit_cleared unmet until a count of TRANSIT or a loss approved by a second person; a transfer overdue past its expected arrival is listed on the Transfers screen"
screen: Transfers
path: { process: inventory.transfer, choices: {} }
reference: "SAP stock transport order with stock in transit (two-step 303 / 305, or 351 / 101 with planned delivery time), in-transit stock in MMBE; Odoo two-step inter-warehouse resupply"
- id: INV-55
kind: person
actor: warehouse_manager
story: As the warehouse manager, I want to put stock into quality inspection or blocked status where it stands, so that a doubtful pallet is held without anyone moving it across the warehouse.
given: "WH1/A/02 (default status unrestricted, allowing all three) holds 36 LTR of COLA-075 lot L-2612; WH1/QC allows only quality"
when: "I set 18 LTR of L-2612 at WH1/A/02 to quality, then release 12 LTR and block 6, then try to put unrestricted stock into WH1/QC by a transfer"
then: "each change is a move from (WH1/A/02, unrestricted) to (WH1/A/02, quality) and back, same bin, no value change, naming who and why; available at WH1 drops by 18 LTR and rises by 12; the blocked 6 LTR stay on hand and valued; the transfer into WH1/QC arrives as quality (the location's only allowed status); a reservation or a pick never takes quality or blocked stock; stock type is part of every stock row (product, location, lot, stock type, owner), and a location's status is its default and its limit (v3)"
screen: Stock
path: { process: inventory.stock, choices: {} }
reference: "SAP stock types per quant (unrestricted, quality inspection, blocked) and transfer postings 321 / 322, 343 / 344, 349 / 350 without a change of storage location"
# ── system ────────────────────────────────────────────────────────────────────────────────
- id: INV-30
kind: system
actor: system
trigger: on read (derived)
story: The system derives on hand, reserved and available from the moves and the reservations whenever they are asked, so that no stored quantity can drift from the history.
given: "moves into WH1/A/01 of 144 LTR and out of 108 LTR; reservations of 18 LTR at WH1"
when: on hand is asked for WH1/A/01, for the subtree WH1, and as of yesterday 17:00
then: "36 LTR; the subtree is the sum over every internal location whose code starts with WH1/; as of a past moment only moves recorded by then count; asking twice gives the same answer"
reference: not built
- id: INV-31
kind: system
actor: system
trigger: in the same request as each move that changes a company's quantity of a product
story: The system keeps the average cost per product per company by appending a valuation row per move, so that every delivery is costed at what the stock cost.
given: "COLA-075 at UK1: 144 LTR worth 172.80 (average 1.2000)"
when: 72 LTR are received at 1.3500, then 90 LTR delivered, then the last 126 LTR delivered
then: "rows: +72 worth 97.20 (216 LTR, 270.00, average 1.2500); -90 worth -112.50 (126 LTR, 157.50); -126 worth -157.50 (0 LTR, 0.00): the move that empties the product takes the remaining value, never quantity x average, so no residue is left; each row is keyed (company, product, seq) and claimed with a plain INSERT, so two concurrent moves cannot both take the same seq; a transfer between warehouses of one company appends nothing"
reference: not built
- id: INV-32
kind: system
actor: system
trigger: when a receipt, delivery, return or applied count is done, and again on retry while a posting is pending
story: The system asks the ledger to post the value of each stock movement, so that the stock account always equals the stock.
given: ledger mode post; the account map has stock_valuation, stock_received_not_billed, cost_of_goods_sold and inventory_loss for UK1
when: delivery WH1/OUT/00001 worth 21.60 is done
then: "one journal (cost_of_goods_sold debit 21.60 / stock_valuation credit 21.60) with journal_id derived from (document, event), so posting again changes the ledger once; a refusal (a closed period, a missing account) leaves the posting pending with the ledger's reason, the stock move stands, and the Valuation screen lists it to fix; in export mode nothing is posted and the month's movements are a file for the accountant"
reference: not built
- id: INV-33
kind: system
actor: system
trigger: when a count is submitted and the step is on
story: The system suggests a likely cause for each large count difference, so that the approver checks the obvious first.
given: count C-15 shows WH1/A/01 short 18 LTR of COLA-075, and a transfer of 18 LTR to WH1/B/07 the same morning
when: the count is submitted with ai_explain_variance on (suggest)
then: "the line carries a suggestion (\"possibly the 18 LTR moved to WH1/B/07 at 09:12 was put in WH1/A/01: recount WH1/B/07\") marked as a suggestion; nothing is adjusted or approved by it; the approver's decision on it is logged with who"
reference: not built
- id: INV-42
kind: system
actor: system
trigger: on the first business day of each month (the composition's clock), for every warehouse
story: The system classifies each product A, B or C per warehouse by the value that moved out in the last twelve months, so that counting effort follows value.
given: "WH1, last twelve months' issues and deliveries valued: COLA-075 40,000.00, SCREW-M4 8,000.00, GLOVES 900.00, nine others 1,100.00 together; thresholds A to 80%, B to 95%"
when: the classification runs on 2026-11-02, and again the same day, and the warehouse manager overrides GLOVES to B with a reason
then: "each product is classed by the cumulative share of the products above it: COLA-075 starts at 0% (A), SCREW-M4 at 80% (B), GLOVES at 96% and the rest after it (C); dated rows (warehouse, product, valid_from) from today; the re-run writes nothing; the override is a dated row with who and why, kept until the next run that would change the class names it as overridden; nothing about it changes stock or value"
reference: "SAP ABC analysis for cycle counting (MIBC by consumption or requirement value, thresholds per plant)"
# ── integration ───────────────────────────────────────────────────────────────────────────
- id: INV-20
kind: integration
actor: system
trigger: when purchasing records a receipt line, from its own screen or from this module's scanner (INV-21)
story: Purchasing posts each received line here, so that stock moves are owned by one module and a delivery is keyed once.
given: purchasing's receipt R-1 of 8 PK24 of COLA-075 on PO-0101 line 1 into WH1/A/01, received 2026-10-21, at 21.6000 a pack
when: purchasing calls POST /receipts twice with move_ref pur:R-1:1
then: "one move SUPPLIER -> WH1/A/01 of 144 LTR, converted exactly through product, dated the received_on date, valued 172.80, answered {move_id, quantity_base: \"144\"}; the second call returns the same move and records nothing; a unit that does not convert exactly is refused with product's reason and nothing is moved"
owner: inventory
reference: not built
- id: INV-21
kind: integration
actor: system
trigger: when goods-in submits a scanned delivery on this module's Receipts screen
story: The scanner records the receipt through purchasing, so that there is one way a receipt is recorded and purchasing owns the order and its matching.
given: 8 PK24 scanned against PO-0101 line 1 into WH1/A/01, delivery note DN-881
when: the scan is submitted
then: "purchasing's POST /purchase-orders/PO-0101/receipts is called once with delivery note DN-881; purchasing answers the receipt with a move_ref per line, each line already posted here (INV-20); the lots and serials scanned are attached to the moves those move_refs name; submitting DN-881 again records once; the scanner never posts a supplier move itself"
owner: purchasing
reference: not built
- id: INV-22
kind: integration
actor: system
trigger: when a sales order is confirmed, amended or cancelled
story: The sales order asks this module to reserve (or release) stock for its goods lines, so that what is promised is held.
given: SO-2026-0012 confirmed, revision 1, line 1 48 C62 of COLA-075 at 0.75 LTR each; 45 LTR available at WH1
when: sales-order calls POST /reservations/sales-orders/SO-2026-0012 with revision 1, twice
then: "36 LTR reserved for line 1 at WH1, short 0; the second call changes nothing; revision 2 with 24 C62 reduces the reservation to 18 LTR; a release (cancellation) frees it all; a service line is ignored and said so; the answer is each line's readiness {gate, met, reason, fix}"
owner: inventory
reference: "not built: sales-order 0.x does not call this yet (it has no outbound seam for stock); until it does, the warehouse manager reserves from the Deliveries screen (INV-13), which reads the order through GET /sales-orders/{sales_order_id}"
- id: INV-23
kind: integration
actor: system
trigger: when a delivery or a customer return is done
story: This module records on the sales order what was delivered, through the order's own route, so that the order's fulfilment and its invoicing follow the warehouse.
given: delivery WH1/OUT/00001 of 24 C62 on SO-2026-0012 line 1
when: it is validated, and the call is repeated
then: "sales-order's record_delivery is called with quantity 24 in the line's own unit, delivery_ref inv:WH1/OUT/00001 and delivered_on today; the order records it once; a refusal by the order (more than ordered, an order no longer confirmed) refuses the whole validation and nothing is written here; v3: before raising and validating it reads GET /sales-orders/{sales_order_id}/fulfilment (blocks, complete delivery, tolerances, the order type's special stock and purpose); a customer return is recorded on its returns order (INV-14), never as a negative on the original"
owner: sales-order
reference: "the route exists (erp-brick-sales-order POST /sales-orders/{sales_order_id}/deliveries)"
- id: INV-24
kind: integration
actor: system
trigger: from INV-32
story: Stock journals are posted by the ledger, never written here.
given: the ledger installed and post mode on
when: a posting is due
then: "a balanced journal reaches the ledger's journal route; the ledger owns the journal, the period and the balance"
owner: gl
reference: "the route ar already calls (POST /journal-entry/journals); the gl spec (written in parallel) confirms it"
- id: INV-25
kind: integration
actor: system
trigger: when a document line names a product and a unit
story: Quantities are converted by the product module, and a unit used here is frozen there, so that a pack size cannot change under stock.
given: COLA-075 with PK24 = 24 bottles = 18 LTR
when: a receipt line of 8 PK24 is written
then: "the product's convert route answers exactly (144 LTR, 18/1); the move stores the quantity in LTR, the document's unit, numerator and denominator; product's unit-used route is called once for PK24; a conversion the product cannot make exactly within the base unit's decimals is refused with the product's reason"
owner: product
reference: "the routes exist (erp-brick-product GET /products/{product_id}/convert, POST /products/{product_id}/units/{unit_code}/used, GET /gtin/{gtin})"
- id: INV-26
kind: integration
actor: system
trigger: on a one-off migration at cut-over
story: The Odoo stock comes over once as opening balances, reconciled per product and location and in value, and can be re-run safely.
given: an Odoo database with two warehouses, 300 stocked products, lots on 20 of them and an average cost per product
when: the migration runs twice and the reconciliation is computed
then: "one opening move per (product, location, lot) from OPENING, dated the cut-over date, valued at Odoo's cost; the second run writes nothing; every check in migration.odoo.reconcile is equal or explained; once the customer signs the reconciliation, a later run refuses to rewrite it and lists a difference as an adjustment to make"
owner: inventory
reference: not built
- id: INV-27
kind: integration
actor: system
trigger: when another module asks a product's cost or availability
story: Sales and invoicing read availability and the average cost here, so that a promise date and a margin use the stock's own figures.
given: COLA-075 with 27 LTR available at WH1 and an average cost of 1.2500 at UK1
when: sales-order asks availability, and the order admin asks the cost for margin
then: "availability answers per warehouse without bins or value; the cost answers only to a caller holding inv:valuation:read; product's standard cost_amount is left untouched by this module"
owner: inventory
reference: not built
- id: INV-28
kind: integration
actor: system
trigger: when purchasing records a return to the supplier
story: Purchasing posts a return to the supplier here, so that the goods leave stock at the cost they came in at.
given: receipt R-1 posted 144 LTR into WH1/A/01 (move_ref pur:R-1:1); 2 PK24 are damaged
when: purchasing calls POST /returns-to-supplier with move_ref pur:RET-1:1 reversing pur:R-1:1, twice
then: "one move WH1/A/01 -> SUPPLIER of 36 LTR valued at the receipt's cost (43.20), answered {move_id, quantity_base: \"36\"}; the second call records nothing; a return when the stock is no longer at WH1/A/01 is refused (409, \"WH1/A/01 holds 20 LTR, 36 asked\") and nothing moves"
owner: inventory
reference: not built
- id: INV-45
kind: integration
actor: system
trigger: when a consignment order (sales-order SO-45) is delivered, issued, picked up or returned
story: Stock kept at a customer stays ours and valued, apart from what we can sell, until the customer uses it.
given: "7311 consignment location UK1:CONSIGNMENT:7311 (kind consignment_customer, created on first use); a CONS_FILL order of 240 C62 COLA-075"
when: "inventory delivers the CONS_FILL, then the CONS_ISSUE of 60, the CONS_PICKUP of 24 and the CONS_RETURN of 6, and sales-order asks GET /consignment-stock/customers/7311"
then: "the fill is a move WH1/A/01 -> UK1:CONSIGNMENT:7311 of 180 LTR, no value change, no cost of sales, not recorded as a delivery on the order's invoicing (the order type says so in its fulfilment answer); the issue moves 45 LTR to UK1:CUSTOMER at the average (cost of sales) and calls record_delivery on the CONS_ISSUE order; the pick-up moves 18 LTR back to WH1; the return receives 4.5 LTR from UK1:CUSTOMER into quality; the stock answer is 162 C62 (121.5 LTR); consignment stock is on hand and valued, never available, reserved or promised to anyone else"
owner: inventory
reference: not built
- id: INV-47
kind: integration
actor: system
trigger: when purchasing's subcontract order is sent (components provided) and when the subcontracted product is received
story: Components we give a subcontractor stay ours at their site, and are consumed into the finished product when it comes back, so that its value is the components plus the service.
given: "purchasing's subcontract order PO-0160 with subcontractor 9200 for 100 C62 of KIT-A at 3.0000 service each, components 100 C62 BOX-S and 200 C62 SCREW-M4 (listed on the order, no bill of materials needed)"
when: "the warehouse manager ships the components (a transfer to UK1:SUBCONTRACT:9200), then purchasing posts the receipt of 100 KIT-A through POST /receipts with source_kind subcontract_receipt and the components consumed, twice"
then: "the components sit at UK1:SUBCONTRACT:9200 on hand and valued, never available; the receipt is one move SUPPLIER -> WH1/A/09 of 100 KIT-A and two consumption moves out of UK1:SUBCONTRACT:9200 (100 BOX-S, 200 SCREW-M4) at their average; KIT-A's receipt value = the components' value + 300.00 service, so stock value is moved, not created; a component short at the subcontractor is refused (409, naming it) and nothing is moved; the repeat records nothing"
owner: inventory
reference: "SAP subcontracting (item category L, special stock O, movement types 541 provision and 543 consumption with 101 receipt)"
- id: INV-49
kind: integration
actor: system
trigger: when sales-order promises dates (SO-35) or re-promises them (SO-36)
story: Inventory answers what can be promised by date, from stock, receipts and transfers on their way, so that a promise uses the same figures as the warehouse.
given: "COLA-075 at WH1: unrestricted 360 LTR (of which lot L-2601 9 LTR expired), reserved 180 LTR with dates; purchasing expects 720 LTR on 2026-11-09; goods-in 1 day; a transfer of 36 LTR arriving 2026-11-04"
when: "sales-order asks GET /atp/COLA-075?warehouse_id=WH1&quantity=480&unit_code=C62&requested_on=2026-11-03&min_shelf_life_days=60"
then: "available_now 171 LTR (360 - 9 expired - 180 reserved), plus 36 LTR from 2026-11-04 and 720 LTR from 2026-11-10; the 360 LTR asked are confirmed 171 now, 36 on 2026-11-04 and 153 on 2026-11-10; stock expiring within the minimum shelf life of its date is not counted; quality, blocked and customer-consignment stock never; expected receipts read from purchasing's GET /expected-receipts, never from pur_ tables; nothing is reserved by asking"
owner: inventory
reference: not built
- id: INV-54
kind: integration
actor: system
trigger: when a replenishment proposal to buy is accepted (or sent in auto mode)
story: An accepted proposal becomes one purchase request in purchasing, so that purchasing owns the buying and the proposal knows what became of it.
given: "the WH1 proposal for 60 PK24 of COLA-075 accepted on 2026-11-02"
when: "inventory calls POST /purchase-requests/from-replenishment twice with request_ref inv:repl:WH1:COLA-075:2026-11-02"
then: "purchasing answers one request_id; the proposal is marked sent with it; the second call returns the same; the next night's projection counts the request as expected until purchasing reports it ordered (then the order's expected receipt) or cancelled (then the proposal is open again)"
owner: purchasing
reference: not built
- id: INV-57
kind: integration
actor: system
owner: inventory
trigger: when purchasing approves a bill whose actual delivery cost differs from what the receipt was valued at
story: Purchasing hands the delivery-cost difference here, so that stock still on hand carries its real landed cost and the rest goes to price difference.
given: receipt R-77 of 100 PK24 of COLA-075 valued at 1,800.00 including 50.00 planned freight; 60 PK24 of it still on hand; the forwarder bills 80.00
when: purchasing calls POST /valuation-adjustments with adjustment_ref pur:B-912:1 and amount 30.00
then: 18.00 (60 of 100) is added to the value on hand of that receipt's stock and 12.00 posts to price difference, one journal through gl; a repeat with the same adjustment_ref changes nothing; under standard cost the whole 30.00 is price difference
# ── v6 (2026-10-25, wave 3): stock that another module consumes, produces or restocks ──────
- id: INV-58
kind: integration
actor: system
owner: inventory
trigger: when a till sale closes (pos POS-27) and when a production order issues components (manufacturing MFG-20)
story: Another module asks this one to take stock out for what it sold or used, so that the move, its lot and its value are inventory's and the cost is posted once.
given: 'LON-SOHO''s consumption location LON-SOHO/USE holds 3.200 KGM of BEANS-ETH at an average cost of 18.0000 a KGM; receipt R-5521 sold two lattes whose recipe uses 0.018 KGM each; production order UK01-MO-2026-00118 issues 84.000 KGM of GREEN-ETH, lot GE-0412'
when: 'pos calls POST /goods-issues/from-source with source_kind pos_sale and move_refs pos:R-5521:1:1 and pos:R-5521:2:1; manufacturing calls it with source_kind production_issue and account_purpose wip; each call is repeated'
then: >-
one move per line keyed move_ref (the repeat answers the same moves and changes nothing); pos's two moves of 0.018
KGM at 18.0000 are valued 0.32 and 0.33 (each rounded once, the remainder on the last so the issue totals
round(0.036 x 18.0000) = 0.65), journal cost_of_sales / stock_valuation; the production issue is valued at its lot's
cost and posts wip / stock_valuation; each answer lists {move_ref, move_id, quantity_base, value}; a pos_sale is
never refused for stock (the sale has happened: a short location goes negative and is listed for the stock
controller), a production_issue short of stock is refused (gate on_hand) unless the location allows negative stock;
the lot is the one named, else FEFO. v7: an output or by-product received in error comes back out the same way,
source_kind production_output_reversal naming reverses_move_ref "mfg:UK01-MO-2026-00118:output:1": the move leaves
at the value it came in (the reversed move's), debits wip and credits stock_valuation, from the same lot; it is
refused 409 {gate: on_hand} when that lot no longer holds the quantity (it was sold or used: correct it there first)
and once per reversed move
- id: INV-59
kind: integration
actor: system
owner: inventory
trigger: when a production order reports output or a by-product (MFG-20), and when a till refund puts goods back on the shelf (pos POS-11)
story: Another module hands this one what it produced or took back, so that the stock is received at the right cost and a lot can be traced back to what it was made of.
given: 'UK01-MO-2026-00118 reports 82.500 KGM of ROAST-ETH as lot RE-1180 at standard 9.3899, from input lot GE-0412 (84.000 KGM), and 2.000 KGM of CHAFF at its net realisable value 0.5000; refund R-5530 restocks one BAG-ETH-250 sold on R-5521'
when: 'manufacturing calls POST /receipts with source_kind production_receipt and by_product_receipt; pos calls it with source_kind pos_refund_restock naming reverses_move_ref pos:R-5521:3:1'
then: >-
RE-1180 is received at 774.67 (82.500 x 9.3899 = 774.66675, rounded once) and credits wip, the chaff at 1.00 and credits wip;
a lot link RE-1180 <- GE-0412 (84.000 KGM) is recorded, so GET /lots/ROAST-ETH/RE-1180/trace walks back to the
green beans and GET /lots/GREEN-ETH/GE-0412/trace forward to every bag; the restocked bag is received at the cost
it left at (the reversed move's value) and credits cost_of_sales; each is idempotent on move_ref; purchasing's
source kinds are unchanged
- id: INV-60
kind: integration
actor: system
owner: inventory
trigger: when manufacturing releases a cost roll-up (MFG-07)
story: A released roll-up becomes this module's dated standard prices, so that the standard is the one a second person approved and the stock is revalued on the day it starts.
given: 'UK01 values at standard; roll-up CR-2026-12 released by cost accountant B with ROAST-ETH 9.6100 a KGM from 2026-12-01'
when: 'manufacturing calls POST /standard-prices with source_ref mfg:CR-2026-12, twice; the accountant had already scheduled 9.5000 for 2026-12-01 by hand'
then: >-
without the accountant's row: a scheduled standard price (UK01, ROAST-ETH, 2026-12-01, 9.6100) with set_by B and
source_ref, the repeat answers the same; revalued on 2026-12-01 by apply_standard_price (INV-48); with the
accountant's different row for the same date the call is refused 409 (gate standard_price_taken: "a standard price
of 9.5000 is already set for 2026-12-01 by <who>"), never overwritten; a valid_from of today or earlier is refused
(gate starts_after_today); a company valued at average cost is refused (gate values_at_standard)
- id: INV-61
kind: integration
actor: system
owner: inventory
trigger: when the nightly replenishment runs and manufacturing is installed
story: Replenishment counts what production will consume, so that components are bought in time for planned production, and leaves made products to production's planning.
given: 'GREEN-ETH at ROASTERY (buy, reorder point 500 KGM): 900 KGM on hand, 1,200 KGM needed by planned orders within the horizon; ROAST-ETH at ROASTERY (make, mrp)'
when: the proposal runs
then: >-
GREEN-ETH's projected available is 900 - 1,200 = -300 KGM, at or below its reorder point, so a buy proposal is
made as for any product (rounded by product, sent through purchasing); the requirements are read from
manufacturing's GET /dependent-requirements for the site and the horizon, never from mfg_ tables; ROAST-ETH is not
proposed (procurement make or planning mrp: manufacturing's MRP-lite plans it)
processes:
- key: warehouse
pcf_id: null # APQC PCF v8.0 ids for warehousing are not yet verified in erp-bench/standards; fill before sign-off
pcf_name: Operate warehousing (set up the storage locations)
reference_names: ["Warehouse management", "Locations"]
brick: inventory
- key: stock
pcf_id: null
pcf_name: Manage inventory (on hand, availability, corrections)
reference_names: ["Plan to Inventory", "Stock overview"]
brick: inventory
- key: receipt
pcf_id: null
pcf_name: Receive, inspect and store inbound deliveries
reference_names: ["Procure to Pay (goods receipt)", "Receipts"]
brick: inventory
- key: delivery
pcf_id: null
pcf_name: Fulfil customer orders (pick, pack, ship)
reference_names: ["Order to Cash (delivery)", "Deliveries"]
brick: inventory
- key: transfer
pcf_id: null
pcf_name: Track inventory (internal moves)
reference_names: ["Internal transfers"]
brick: inventory
- key: stock_count
pcf_id: null
pcf_name: Perform physical inventory counts and reconcile
reference_names: ["Physical inventory", "Cycle count"]
brick: inventory
- key: valuation
pcf_id: null
pcf_name: Account for inventory (valuation)
reference_names: ["Inventory valuation", "Record to Report (stock)"]
brick: inventory
- key: goods_issue
pcf_id: null
pcf_name: Issue stock for internal use and scrap it (v3)
reference_names: ["Goods issue to cost centre", "Scrapping"]
brick: inventory
- key: replenishment
pcf_id: null
pcf_name: Plan and replenish inventory (v3)
reference_names: ["Consumption-based planning", "Reordering rules"]
brick: inventory
- key: standard_price
pcf_id: null
pcf_name: Set standard costs and revalue stock (v3)
reference_names: ["Standard price release", "Revaluation"]
brick: inventory
workflows:
- entity: receipt
states: [scanned, done]
terminal: [done]
transitions:
- { key: scan_delivery, name: Scan what arrived, from: none, to: scanned, by: stock_operator, gate: "readiness: on_order (purchasing's GET /purchase-orders/{purchase_order_id}/expected), units_exact, tracking_complete, within_tolerance; the bin is internal and not archived", effect: "purchasing's POST /purchase-orders/{purchase_order_id}/receipts called once, keyed by the delivery note: the ONE way a receipt is recorded; nothing in stock until purchasing posts each line back" }
- { key: receive_customer_return, name: Receive a customer return against its RMA, from: none, to: done, by: stock_operator, gate: "readiness: rma_open (sales-order's expected return for that customer and product), within_rma, serial_was_delivered (a serial delivered to that customer)", effect: "numbered {warehouse}/RET/{seq:5}; a move CUSTOMER -> the return location in stock type quality where the reason inspects, valued at the cost it left at; record_delivery on the returns order (v3)" }
- { key: inspect_return, name: Inspect the returned goods, from: done, to: done, by: stock_operator, gate: "the return is in quality stock; every unit decided", effect: "restock (a type change to unrestricted, or a putaway), scrap (a goods issue, purpose scrap) or back to the customer at the value it came in; accepted and rejected reported to sales-order once (POST /sales-orders/{sales_order_id}/return-inspections)" }
- { key: receive_from_source, name: "Receive what another module produced or took back (v6, INV-59)", from: none, to: done, by: system, gate: "readiness: source_kind production_receipt | by_product_receipt | pos_refund_restock; move_ref unique (a known one answers its move); units convert exactly; the location is internal; a pos_refund_restock names the move it reverses", effect: "numbered {warehouse}/IN/{seq:5}; a move PRODUCTION (or CUSTOMER) -> location; valued at unit_cost (production: standard or the order's cost; by-product: its net realisable value) or, for a restock, at the reversed move's cost; credits wip (production) or cost_of_sales (restock); a lot link per input lot named" }
- { key: post_receipt, name: Post the received stock, from: scanned, to: done, by: system, gate: "purchasing calls POST /receipts per line, keyed move_ref pur:<receipt_id>:<line_no> (a receipt recorded on purchasing's own screen starts here)", effect: "numbered {warehouse}/IN/{seq:5}; a move SUPPLIER -> location per line, converted exactly, valued at quantity_priced x unit_cost / pricing_quantity rounded once (v2: purchasing's pricing unit and quantity); the unit's weights and volume copied onto the line (v2); average cost appended; scanned lots and serials attached; the valuation posted; {move_id, quantity_base} answered" }
note: "v3: a receipt lands in WH1/IN when receipt_steps is two and a putaway transfer follows (put_away_stock); a supplier-consignment line carries the owner and no value; a subcontract receipt consumes the components at the subcontractor (INV-47). What is still to arrive is purchasing's (its outstanding quantities), never an inventory document; a return to the supplier is purchasing's return row posted through POST /returns-to-supplier (INV-28), valued at the receipt's cost; a return from a customer is a receipt from CUSTOMER naming the delivery it reverses (INV-14), valued at the cost it left at"
- entity: delivery
states: [waiting, ready, done, cancelled]
terminal: [done, cancelled]
transitions:
- { key: reserve_order, name: Reserve stock for the confirmed order, from: none, to: waiting, by: system, gate: "a confirmed sales order with goods lines", effect: "reservation rows per line at the warehouse, as much as is available (never from a quality or blocked location, v2); the shortfall stated" }
- { key: reserve_by_hand, name: Reserve or release by hand, from: waiting, to: waiting, by: warehouse_manager, requires: reason, gate: "no more than is available", effect: "a reservation row appended, with who and why" }
- { key: stock_arrives, name: Stock becomes available, from: waiting, to: ready, by: system, gate: "every line reserved in full, or the order accepts part shipments", effect: "the delivery appears on the pick list" }
- { key: batch_deliveries, name: Batch deliveries into one pick, from: ready, to: ready, by: warehouse_manager, gate: "deliveries of one warehouse, ready, not in another batch", effect: "a pick batch {warehouse}/PB/{seq:5} with one pick list in pick_sequence order (v3)" }
- { key: pick_goods, name: Pick from the bins, from: ready, to: ready, by: stock_operator, gate: "each bin holds what is picked from it (unless negative stock is allowed there) and the stock picked is unrestricted (v3: by stock row); a lot or serial per tracked unit; readiness: shelf_life (the lot proposed by the removal strategy, FEFO or FIFO, with the customer's minimum remaining shelf life; another lot only by a warehouse manager with a reason; never an expired lot unless the owner allows it); the line's unit converts exactly to the order line's unit", effect: "picked quantities recorded on the delivery, proposed in the warehouse unit (v2) and by lot (v3); with two-step picking, a transfer bin -> staging" }
- { key: pack_delivery, name: Pack into packages, from: ready, to: ready, by: stock_operator, gate: "contents never more than picked; a package type", effect: "packages with an SSCC each (claimed, never reused), contents and gross weight (v3)" }
- { key: validate_delivery, name: Validate the delivery, from: ready, to: done, by: stock_operator, gate: "readiness: something_picked, stock_claimed (the claim rows on every bin and the warehouse were won), order_accepts (the sales order's record_delivery answered ok), weights_known (v2, only with delivery_needs_weights), not_blocked (v3: the order's fulfilment answer has no delivery block or incomplete delivery field), all_packed (v3, only with pick_pack_ship)", effect: "numbered {warehouse}/OUT/{seq:5}; gross and net weight and volume answered for shipping (v2); supplier-consignment stock picked is withdrawn first (purchasing told, v3); moves bin -> CUSTOMER at average or standard cost, or to the customer's consignment location for a CONS_FILL order (v3); the reservation consumed; the order records the delivery; the cost of goods posted to the purpose's account (sale, free_of_charge, consignment issue); what is left goes to a back-order delivery unless the order asks for complete delivery or the line closed within tolerance" }
- { key: cancel_delivery, name: Cancel the delivery, from: ready, to: cancelled, by: warehouse_manager, requires: reason, gate: "nothing on it validated", effect: "its reservations released" }
note: "a delivery is undone only by a return receipt (INV-14). Two validations for the last unit: one wins the claim row, the other is refused and writes nothing (INV-08)"
- entity: transfer
states: [draft, in_transit, done, cancelled]
terminal: [done, cancelled]
transitions:
- { key: plan_transfer, name: Plan the move, from: none, to: draft, by: warehouse_manager, gate: "both locations internal, of one company (a subcontractor's or a customer's consignment location of the company counts as one, v3)", effect: "a draft transfer; between an unrestricted and a quality or blocked location it is a release or a block (v2); a transfer to a subcontractor provides components (v3); from a replenishment proposal, the supplying site's stock (v3)" }
- { key: put_away_stock, name: Put away from goods-in, from: draft, to: done, by: stock_operator, gate: "readiness: bin_capacity (quantity, weight and volume within the bin's capacity, from the copied measures)", effect: "the bin proposed by the putaway rules (fixed bin, addition to stock, next bin with room), or the one chosen with who; no value change (v3)" }
- { key: provide_to_subcontractor, name: Send components to a subcontractor (v3), from: none, to: done, by: system, gate: "purchasing's subcontract order line names them (POST /subcontract-provisions, idempotent on move_ref); the source location holds them", effect: "a transfer to the subcontractor's location as stock still ours (special stock O), consumed later by the subcontract receipt" }
- { key: change_stock_type, name: Change the stock type where it stands, from: none, to: done, by: warehouse_manager, requires: reason, gate: "the location accepts the new type; the stock is there", effect: "a move between types at the same location, no value change; blocking a lot changes it everywhere it is (v3)" }
- { key: move_within_site, name: Move between bins, from: draft, to: done, by: stock_operator, gate: "both bins in one warehouse; the source holds it; a lot or serial per tracked unit", effect: "numbered {warehouse}/INT/{seq:5}; one move bin -> bin; no value change" }
- { key: ship_to_site, name: Ship to the other warehouse, from: draft, to: in_transit, by: stock_operator, gate: "the source holds it", effect: "a move bin -> TRANSIT; counted in transit, available nowhere" }
- { key: receive_at_site, name: Receive at the other warehouse, from: in_transit, to: done, by: stock_operator, gate: "no more received than was shipped", effect: "a move TRANSIT -> bin; a shortfall is left in transit until a count of TRANSIT or a loss approved by a second person is applied (gate transit_cleared, v3); expected_arrival_on was set when shipped, from the site's planned days" }
- { key: cancel_transfer, name: Cancel the planned move, from: draft, to: cancelled, by: warehouse_manager }
note: "a move recorded by mistake is reversed by a new move naming it (INV-15), never edited"
- entity: stock_count
states: [counting, submitted, approved, applied, rejected]
terminal: [applied]
transitions:
- { key: start_count, name: Start a count, from: none, to: counting, by: warehouse_manager, gate: "an internal location subtree, or a list of products over every bin that holds them (v3); no other open count on it", effect: "every product (and stock type) with stock in scope listed, with no expected quantity shown to the counter" }
- { key: record_counted, name: Record what is on the shelf, from: counting, to: counting, by: stock_operator, gate: "a quantity within bounds and the unit's decimals, in any unit of the product (v2); a lot or serial for a tracked product", effect: "one or more entries per line (5 PK24, 6 C62) summed exactly into counted_base; counted_at per line; expected is what the moves said at counted_at" }
- { key: submit_count, name: Submit the count, from: counting, to: submitted, by: warehouse_manager, effect: "differences and their value at average cost fixed on the count" }
- { key: ai_explain_variance, name: "AI suggests likely causes (suggest)", from: submitted, to: submitted, by: system, gate: "the step is on and a line's difference is over the threshold", effect: "a suggestion per line; nothing adjusted" }
- { key: apply_small, name: Apply a count under the threshold, from: submitted, to: applied, by: warehouse_manager, gate: "the sum of the absolute VALUE differences across the lines is under count_approval_threshold, and no single line's absolute value difference is at or above count_line_value_limit", effect: "an adjustment move per line between the bin and LOSS; the loss or gain posted" }
- { key: approve_count, name: A second person approves the count, from: submitted, to: approved, by: stock_controller, approval: true, gate: "readiness: approver_not_counter (neither a counter nor the submitter, by email and by sign-in subject)", effect: "the decision recorded" }
- { key: apply_approved, name: Apply the approved count, from: approved, to: applied, by: system, effect: "adjustment moves; the loss or gain posted" }
- { key: reject_count, name: Reject and recount, from: submitted, to: rejected, by: stock_controller, requires: reason, effect: "nothing applied" }
- { key: recount, name: Recount, from: rejected, to: counting, by: warehouse_manager, effect: "the lines are recorded again" }
note: "a count never sets a quantity: it appends the difference as moves to or from LOSS. Moves recorded after a line was counted are kept, because the difference is taken at counted_at"
- entity: goods_issue
states: [draft, awaiting_approval, posted, cancelled]
terminal: [posted, cancelled]
transitions:
- { key: plan_goods_issue, name: Plan an issue or a scrap, from: none, to: draft, by: stock_operator, gate: "readiness: cost_centre_valid (the ledger's cost centre, for purpose cost_centre), scrap_reason (for purpose scrap); the product's issue unit proposed (product PRD-29)", effect: "a draft goods issue with its purpose (cost_centre | scrap | internal_use), lines in any unit of the product" }
- { key: post_goods_issue, name: Post the issue, from: draft, to: posted, by: stock_operator, gate: "readiness: stock_claimed (bin and warehouse claim rows won), under_issue_threshold (the value is under issue_approval_threshold)", effect: "numbered {warehouse}/GI/{seq:5}; moves bin -> {company}:CONSUMPTION or :LOSS at average or standard cost; a journal per purpose (consumption with the cost centre on the line, scrap) posted" }
- { key: submit_goods_issue, name: Submit an issue over the threshold, from: draft, to: awaiting_approval, by: stock_operator, gate: "the value is at or above issue_approval_threshold", effect: "waits for a stock controller; nothing moves" }
- { key: approve_goods_issue, name: A second person approves the issue, from: awaiting_approval, to: posted, by: stock_controller, approval: true, gate: "readiness: approver_not_requester (by email AND sign-in subject), stock_claimed", effect: "posted as post_goods_issue" }
- { key: reject_goods_issue, name: Reject the issue, from: awaiting_approval, to: cancelled, by: stock_controller, requires: reason }
- { key: cancel_goods_issue, name: Cancel the draft issue, from: draft, to: cancelled, by: stock_operator }
- { key: issue_from_source, name: "Take out what another module consumed (v6, INV-58)", from: none, to: posted, by: system, gate: "readiness: source_kind pos_sale | production_issue | production_output_reversal (v7: an output or by-product received in error taken back out, naming reverses_move_ref); each move_ref unique (a known one answers its move); units convert exactly; stock_claimed; on_hand for production_issue (a pos_sale posts and may go negative, listed for the stock controller)", effect: "numbered {warehouse}/GI/{seq:5}; moves location -> {company}:CONSUMPTION per line at average or standard cost (the lot named, else FEFO); a journal per call: cost_of_sales (pos_sale) or wip (production_issue) / stock_valuation; {move_ref, move_id, quantity_base, value} answered" }
- { key: reverse_goods_issue, name: Reverse a posted issue, from: posted, to: posted, by: warehouse_manager, requires: reason, gate: "not reversed before; the issue's period open in the ledger", effect: "reversing moves naming the original, valued as it was, and a reversing journal (SAP 202 / 552)" }
readiness:
- { transition: issue_from_source, gate: on_hand, reason: "{location} holds {on_hand} of {product}; the production issue needs {quantity}", fix: "move stock there first, or the owner allows negative stock at the location" }
- { transition: plan_goods_issue, gate: cost_centre_valid, reason: "the ledger has no open cost centre with that code", fix: "pick a cost centre (gl)" }
- { transition: post_goods_issue, gate: under_issue_threshold, reason: "the issue's value is at or above the approval threshold", fix: "submit it for a stock controller" }
- { transition: approve_goods_issue, gate: approver_not_requester, reason: "you planned or submitted this issue (same email or sign-in subject)", fix: "ask another stock controller" }
- entity: replenishment_proposal
states: [proposed, sent, dismissed]
terminal: [sent, dismissed]
transitions:
- { key: propose_replenishment, name: "Propose what to buy or move (nightly)", from: none, to: proposed, by: system, gate: "a product planned at the site (product's GET /product-sites) with procurement buy or transfer (v6: make or mrp is manufacturing's, skipped); projected available (v6: less manufacturing's dependent requirements in the horizon, GET /dependent-requirements, when manufacturing is installed) at or below its reorder point; once per (site, product, business date)", effect: "a proposal: projected figures, the site row's valid_from, quantity to the max (min_max) or the lot size, rounded by product's round-proposal; buy or transfer from the supplying site" }
- { key: accept_replenishment, name: Accept the proposal, from: proposed, to: sent, by: warehouse_manager, gate: "the quantity may be changed; it still meets the purchase rule", effect: "buy: one purchase request in purchasing (POST /purchase-requests/from-replenishment); transfer: a draft transfer from the supplying site" }
- { key: auto_send_replenishment, name: "Send the proposal automatically (auto mode)", from: proposed, to: sent, by: system, gate: "the owner's replenishment_mode is auto from a date on or before today", effect: "as accept_replenishment, by the system's client" }
- { key: dismiss_replenishment, name: Dismiss the proposal, from: proposed, to: dismissed, by: warehouse_manager, requires: reason }
- entity: standard_price
states: [scheduled, effective]
terminal: [effective]
transitions:
- { key: schedule_standard_price, name: Set a standard price from a date, from: none, to: scheduled, by: accountant, gate: "readiness: starts_after_today; the company values at standard on that date, or switches to it that day", effect: "a dated standard price per company and product (NUMERIC(19,4) per base unit)" }
- { key: intake_standard_price, name: "Take a released roll-up's standard price (v6, INV-60)", from: none, to: scheduled, by: system, gate: "readiness: starts_after_today; values_at_standard (the company values at standard on valid_from); standard_price_taken (no other price for that company, product and date)", effect: "as schedule_standard_price, with set_by the releaser and source_ref; idempotent on (company_code, product_id, valid_from, source_ref)" }
- { key: apply_standard_price, name: The standard price takes effect, from: scheduled, to: effective, by: system, gate: "valid_from is today (business_timezone)", effect: "a revaluation row of the quantity on hand at the old and new price, and its journal (stock_valuation against revaluation), journal_id derived (company, product, valid_from), posted once" }
readiness:
- { transition: schedule_standard_price, gate: starts_after_today, reason: "a standard price starts tomorrow at the earliest", fix: "pick a later date" }
- { transition: intake_standard_price, gate: standard_price_taken, reason: "a standard price of {price} is already set for {valid_from} by {who}", fix: "the accountant withdraws theirs, or manufacturing releases for a later date" }
- { transition: intake_standard_price, gate: values_at_standard, reason: "{company} values at average cost on {valid_from}", fix: "the owner switches the company to standard from a later month (INV-48), or release for average cost (no standard price)" }
entities:
- id: warehouse
meaning: a site where stock is kept, belonging to one company
natural_key: "warehouse_id: the customer's own short code (WH1), fixed once set"
key_attributes: [name, company_code, address, allow_negative_stock_from (dated; the owner's control), status (active | archived)]
relationships: ["1..n location"]
master_data: module-owned
reference_table: inv_warehouse
- id: location
meaning: a place stock can be in, real (a zone, a bin) or virtual (supplier, customer, loss, transit, opening)
natural_key: "location_id: the full path code (WH1/A/01), VARCHAR, fixed once a move names it; virtual ones are {company_code}:SUPPLIER, :CUSTOMER, :LOSS, :TRANSIT, :OPENING"
key_attributes: [warehouse_id, parent_location_id, "kind (view | internal | supplier | customer | loss | transit | opening | consumption (v3) | consignment_customer (v3, per customer: {company}:CONSIGNMENT:{bp_id}) | subcontractor (v3, per subcontractor: {company}:SUBCONTRACT:{bp_id}))", "stock_status (v2, internal only: unrestricted | quality | blocked; v3: the DEFAULT type of stock put here)", "allowed_stock_types (v3: default all three; a QC area quality only)", "pick_sequence (v3: the walking order)", "max_quantity_base, max_weight, max_volume (v3: capacity; null = unlimited)", barcode, status (active | archived)]
relationships: ["-> warehouse", "-> location (parent)"]
master_data: module-owned
reference_table: inv_location
note: "a subtree is every location whose code starts with the parent's code and a slash, so on hand per zone is one prefix query, no graph walk"
- id: stocked_product
meaning: how this module treats a product it stocks (its tracking), from a date
natural_key: [product_id, valid_from]
key_attributes: [tracking (none | lot | serial), "removal_strategy (v3: fifo | fefo | manual; default fifo)", "shelf_life_days (v3: sets a lot's expires_on at receipt when the supplier gives none)", set_by]
relationships: ["-> product (product module)"]
master_data: "module-owned (append-only); the product itself references product"
reference_table: inv_stocked_product
note: "a product of kind goods with no row is tracking none; the product module has no stock fields and this module never writes them"
- id: lot_link
meaning: "an output lot made from an input lot (v6, INV-59): the step production