PRD: Stock Locations & Levels
| Module | Inventory | PRD ID | PRD-STK-001 |
| Status | Superseded | FEAT | STK |
| Epic | — | Plane | — |
| Date | 2026-07-13 | Version | v1.0 |
| Packages | @nx/inventory | URD | STK |
| Surface | Client · Owner/MgrBO · Ops | ||
| Owner | Phát Nguyễn | ||
What stock locations and levels are
A merchant can hold goods in several inventory locations: a main warehouse, a bar counter, a kitchen. Several merchants sit inside one organizer. Locations nest into a tree, and every merchant always has exactly one default location.
A stock item is one item at one location. This is where stock is actually recorded: open a stock item and you see where the goods are, how many are left, and how many of those can still be sold. This PRD builds that foundation for the whole Inventory module.
The four stock numbers
These four numbers are the subject of this PRD, and every other Inventory PRD quotes them from here. Each item at each location carries:
| Number | What the system calls it | Meaning |
|---|---|---|
| On-hand | on-hand | The goods physically sitting on the shelf, touchable, countable |
| Reserved | reserved | Already promised to a sale order or a transfer ticket; still on the shelf, but no longer sellable to anyone else |
| Available | available | The part that can still be sold |
| Incoming | incoming | Already left the source location, not yet arrived here; visible, but not yet sellable |
The invariant, true at every moment:
Available = On-hand - ReservedWhy one stock number is not enough
A bubble tea merchant has a bar counter up front and an ingredient store in the back. A retail chain has a main warehouse and many stores. If the system keeps a single "how much is left" number for the whole merchant, the cashier at store A has no idea the goods are sitting at store B, and the person doing a stock count has no idea where to count.
The second problem is the stock number itself. One number cannot answer the question that matters most to a seller: how much can I still sell. Goods on the shelf may already be promised to an order in checkout, or packed into a pending transfer. The seller looks at available, the person doing the count looks at on-hand, and both are right.
One item, from receipt to sale
Coke at the Cold zone. The low-stock threshold on this stock item is 15.
| What happens | On-hand | Reserved | Available | Incoming |
|---|---|---|---|---|
| Receive 10 cans into the Cold zone | 10 | 0 | 10 | 0 |
| The cashier taps 3 cans into an order | 10 | 3 | 7 | 0 |
| The customer pays, the order completes | 7 | 0 | 7 | 0 |
| A transfer ticket sends 5 cans to the Cold zone | 7 | 0 | 7 | 5 |
| The Cold zone counts 5 cans and receives them | 12 | 0 | 12 | 0 |
- Tapping an item into an order does not move on-hand. The goods are still on the shelf; what drops is available.
- Available ends at 12, below the threshold of 15, so the Stock screen flags a low-stock warning right on this stock item's row.
- Open "Main warehouse", the parent of the Cold zone, and you read the rolled-up stock of itself plus every zone inside it.
The easiest thing to get wrong
Available is not a column somebody updates by hand
Available = On-hand - Reserved is enforced directly at the database layer. Any write that breaks the equation is rejected at the storage layer.
A new stock write that updates the numbers in the wrong order still cannot leave behind a stock item whose three quantities disagree. The system does not rely on each code path remembering to do the math right.
The consequence: reserving stock for a sale order drops available immediately, on every POS terminal, without moving on-hand by a single unit.
1. Goals & Non-Goals
Goals
- Let a merchant build an inventory location tree with unlimited depth, distinguishing physical from virtual locations.
- Guarantee every merchant has exactly one default location, even a merchant who has never opened the inventory setup screen.
- Carry four quantities down to each item - location - lot - serial combination.
- Raise low-stock warnings across three threshold tiers, taking the first tier that is set.
- Let a merchant read rolled-up stock for a branch of the location tree, and rolled-up stock for one item across all locations.
- Enforce available = on-hand - reserved at the database layer.
Non-Goals
- Per-holder reservations and the allow-oversell flag - owned by the Reservations feature.
- Inventory valuation - owned by the Valuation feature.
- The content of each ledger line, reason codes, and idempotency - owned by the Inventory ledger feature.
- Lot lifecycle, expiry, and picking strategies - owned by the Lots, expiry & picking strategy feature.
- Inventory tickets, stock transfers, stock counts - owned by
TKT,TRF,CNT. - Reorder points and safety stock - owned by the Replenishment feature.
2. Success Metrics
| Metric | Target / signal |
|---|---|
| Available accuracy | Available matches on-hand minus reserved on every stock item, with no drifting rows |
| Default location coverage | 100% of merchants have exactly one default location, including merchants who never opened the inventory setup screen |
| Oversell prevention | Unintended sales beyond stock trend to zero on merchants that do not allow oversell |
| Warning usefulness | Merchants report that the low-stock list shows what actually needs reordering, with little noise |
| Stock trust | The gap between recorded stock and counted stock narrows over time |
3. Personas & Scenarios
| Persona | Goal in this feature |
|---|---|
| Merchant owner / Manager | Build the location tree, pick the default location, set warning thresholds, fix stock when the recorded number drifts from reality |
| Warehouse staff | Open the Stock screen daily to see what is left, what is reserved for whom, and which stock items are running low - the warning shows on each row |
| Cashier | Get blocked in the cart the moment available is not enough, instead of selling first and finding out later |
| Operations team | View and edit any merchant's locations and stock items from the Back Office when the merchant needs support |
| System | Auto-create the default location and stock items the first time stock has to be recorded, so no operation is ever blocked by "no location selected" |
Core scenario: the merchant owner creates a Main warehouse and nests a Cold zone inside it → goods are received into the Cold zone and the stock item appears with on-hand going up → the cashier taps an item into an order, reserved goes up and available drops immediately → the warehouse staff opens the Stock screen and sees the stock item fall below its threshold with a low-stock warning → the owner reads the rolled-up stock of the Main warehouse and sees exactly the sum of every zone inside it.
4. User Stories
| # | As a | I want | So that |
|---|---|---|---|
| 01 | merchant owner | to build an inventory location tree that mirrors my actual premises (main warehouse, bar counter, kitchen) | I know where my goods physically are |
| 02 | merchant owner | a default location to always exist | I can sell on day one without going through inventory setup first |
| 03 | warehouse staff member | to see all four quantities on a stock item | I know what is on the shelf and what is actually sellable |
| 04 | warehouse staff member | the system to tell me which items are running low against the threshold I set | I can reorder before I run out |
| 05 | manager | to read the rolled-up stock of a whole branch | I do not have to open each zone and add them up |
| 06 | manager | to correct the on-hand quantity directly when the recorded number is off, with a trail of who changed it and why | the recorded number matches reality and stays traceable |
| 07 | cashier | the available quantity to always be right | I never sell something already reserved for another order |
5. Functional Requirements
| # | Requirement | Status | URD ref |
|---|---|---|---|
FR-001 | A merchant creates an inventory location on the Client app with a display name and a location type. • Physical type: a real place that holds goods - main warehouse, store, bar counter, kitchen. • Virtual type: for grouping or planning only. Stock recorded at a virtual location does not count toward the merchant's physical stock total. | 🔶 | URD-LOC-001 |
FR-002 | A location nests under a parent location to form a hierarchy (e.g. Main warehouse > Zone A > Cold shelf), with no depth limit. | 🔶 | URD-LOC-002 |
FR-003 | Each merchant has exactly one default location at any time. Naming another location as the default clears the flag on the previous one automatically, so the merchant never has to unset the old one. | ✅ | URD-LOC-003 |
FR-004 | A merchant who has never created a location still sells and receives goods normally. The system creates a default location the first time it needs to record stock, instead of raising a "no location selected" error. | ✅ | URD-LOC-004 |
FR-005 | A location moves through four states: Draft → Active → Inactive → Archived. • A location that is currently the default cannot be archived or deleted. • Stock and history of an inactive location stay intact and remain readable. • An inactive location disappears from the location pickers for receiving, issuing, and transferring goods. | 🔶 | URD-LOC-005 |
FR-006 | A merchant enters an address for a physical location. • Enter and save the address; reopening the location shows exactly what was entered. • That address prints on the goods receipt document and is used to coordinate delivery. | 🔶 | URD-LOC-006 |
FR-007 | A merchant reads the rolled-up stock of a branch of the location tree: opening "Main warehouse" shows its own stock plus every zone inside it, instead of opening each zone and adding them by hand. | 🚧 | URD-LOC-007 |
FR-008 | The operations team views and manages any merchant's inventory locations from the Back Office, with the same tree structure and the same lifecycle the merchant works with on the Client app. | ✅ | URD-LOC-008 |
FR-009 | An item with stock tracking enabled gets a stock item automatically, the first time the system needs to record stock for it. • An item is either a product variant or a material. • The stock item appears when the variant syncs over from the product catalog, or when goods arrive for the first time through a receipt. • The merchant never has to hand-create a stock item before receiving goods. | ✅ | URD-STK-001 |
FR-010 | Each stock item at a location carries four quantities. • On-hand: what is physically sitting on the shelf. • Reserved: the part already promised to a sale order or a transfer ticket, still on the shelf but not sellable again. • Available: on-hand minus reserved, the number that decides whether a sale can go through. • Incoming: goods that have left the source location and are on their way here - visible, but not yet sellable. | 🔶 | URD-STK-002 |
FR-011 | Available always equals on-hand minus reserved, at every readable moment, including when several stock writes hit the same stock item concurrently. | 🔶 | URD-STK-003 |
FR-012 | An item that tracks lots or serials splits its stock item further. • Lot tracking: the same item at the same location but in a different lot is a separate stock record. • Serial tracking: split down to each serialized unit. • The child records add up to exactly the item's total stock at that location. | 🚧 | URD-STK-004 |
FR-013 | The low-stock warning resolves across three threshold tiers, taking the first tier that is set. • Tier 1: the threshold set on the stock item itself. • Tier 2: the item-level threshold, applied across every location. • Tier 3: the system default, used when both tiers above are empty. • The Stock screen flags the warning on each stock item row; the Materials screen additionally shows a count of items running low. | 🔶 | URD-STK-005 |
FR-014 | A merchant reads an item's stock two ways: rolled up across every location, or broken out per location. | ✅ | URD-STK-006 |
FR-015 | A merchant or the operations team edits a stock item's on-hand quantity directly from the admin screen, with no inventory ticket required. • Used when the recorded number drifts from reality and needs an urgent fix. • The editor picks a reason in the reason field on the form. Left blank, the ledger line carries the reason "stock adjustment". • Every edit writes a line to the inventory ledger with the before and after values. • A ledger viewing screen, on both Client and Back Office, reads those lines back. | 🔶 | URD-STK-007 |
FR-016 | Each stock item records when it was last counted and when it last received goods, so the merchant can tell which stock items have gone a long time without a recount. | 🚧 | URD-STK-008 |
FR-017 | The invariant available = on-hand - reserved is enforced directly at the database layer. • The database rejects any write that breaks the equation. • A new stock write that updates the numbers in the wrong order still cannot leave behind a stock item whose three quantities disagree. | 🚧 | URD-STK-009 |
5.1 Acceptance criteria
- Create a physical location "Main warehouse", then create "Cold zone" with "Main warehouse" as its parent → "Cold zone" shows up as a child of "Main warehouse" in the location tree.
- Create a virtual location to group goods planned for a future receipt → the location shows in the tree with the virtual type label, clearly distinct from a physical location. Record stock there, then read the merchant's physical stock total → the stock at the virtual location is not included.
- With "Bar counter" currently the default, name "Main warehouse" as the default → "Main warehouse" becomes the default and "Bar counter" stops being the default automatically, with nothing to unset by hand.
- A brand-new merchant who has never opened the Inventory locations screen sells their first item on the POS → the sale completes normally, and the system creates a default location and deducts stock there.
- Move a location to Inactive → the state changes on the list immediately, and reopening its stock history shows the old numbers intact. Try to archive or delete the location that is currently the default → the system blocks it. Open the goods receipt form → the inactive location no longer appears in the receiving location picker.
- Enter an address for a physical location, save, and reopen it → the address shows exactly as entered. Print a goods receipt document for that location → the address appears on the document.
- With 30 units in "Zone A" and 20 in "Zone B", both children of "Main warehouse" → open the rolled-up stock of "Main warehouse" → it reads 50.
- Log into the Back Office and open any merchant's inventory locations → the location tree is correct, and the lifecycle states can be changed just as the merchant does on the Client app.
- Create a new product variant with stock tracking enabled → open the Stock screen → the variant already has a stock item with on-hand 0, with nothing to create by hand.
- Open the detail screen of a stock item with on-hand 10 and reserved 3 → available reads 7; after a stock transfer sends 5 units toward this location, incoming reads 5 and available is still 7.
- Two cashiers add an item with available 1 to two different orders at the same time → exactly one reservation succeeds and the other is rejected right in the cart; re-reading the stock item shows available exactly equal to on-hand minus reserved.
- Enable lot tracking for an item and receive two batches creating lot A (10 units) and lot B (5 units) at the same location → the stock detail screen shows two separate rows adding up to exactly 15.
- A material has an item-level threshold of 20 and its stock drops to 15 → open the Materials screen and the "low stock" count goes up by one. Another material has no threshold at all and its stock drops below the system default → it is counted too.
- Stock item A has no threshold of its own and its item has an item-level threshold of 20; A's available drops to 15 → the Stock screen flags A as low stock on its own row, against the threshold of 20.
- Stock item B has its own threshold of 5 and its item has an item-level threshold of 20; B's available drops to 15 → the Stock screen does not flag it, because the stock-item threshold of 5 wins and 15 is still above 5.
- Open the stock view of an item present at three locations → read one rolled-up row covering all three, and expand it into three per-location rows.
- Edit a stock item's on-hand from 12 down to 10 and pick a reason → the quantity changes on the Stock screen immediately, and opening the ledger screen reads that exact line back with before 12, after 10, and the chosen reason.
- Finish a stock count on a stock item, then receive goods into that same item → the stock item detail screen shows the last-counted time and the last-received time, matching the two operations just performed.
- Run several concurrent stock writes on the same stock item, one of them deliberately updating in the wrong order → the bad write is rejected by the database, and the stock item can never be read with three disagreeing quantities.
6. Non-Functional Requirements
| Aspect | Requirement |
|---|---|
| Data integrity | The invariant available = on-hand - reserved is enforced at the database layer, not left to the discipline of each write path |
| Concurrency | Concurrent stock writes on the same stock item produce the result they would if they ran one after another; no write silently overwrites another's contribution |
| Uniqueness | Exactly one default location per merchant, and exactly one stock item per item - location - lot - serial combination |
| Merchant scoping | Locations and stock items are per-merchant data; deletion is soft and restorable |
| Precision | Stock quantities keep 4 decimal places, enough for ingredients measured in grams and millilitres |
| Performance | The Stock screen loads the stock item list together with its low-stock warnings in a single read, not one extra call per row |
| i18n | Location names and display labels are bilingual |
7. UX & Flows
Building locations and recording stock for the first time:
How the four quantities move on a sale:
Rolling stock up a branch of the tree:
8. Data & Domain
| Concept | Role |
|---|---|
| Inventory location | A place that holds goods, physical or virtual; nests into a tree; exactly one is the default per merchant |
| Stock item | An item tracked for stock - a product variant or a material |
| Stock record | The four quantities of a stock item at a location, split further by lot and serial when the item tracks them |
| On-hand | What is physically sitting on the shelf |
| Reserved | The part already promised to a sale order, a transfer ticket, or a production order |
| Available | On-hand minus reserved; the number that decides whether a sale can go through |
| Incoming | Goods that have left the source location and are on their way here; used for internal transfers only |
| Low-stock threshold | The level below which a low-stock warning fires; set on the stock item, on the item, or taken from the system default |
Conceptual level only - the full schema and invariants live in the inventory domain model.
9. Dependencies & Assumptions
Depends on
| # | Feature | What is depended on |
|---|---|---|
| 01 | Product catalog | a product variant with stock tracking enabled is what spawns a stock item; the tracking flag is set there. |
| 02 | MAT Materials | a material with stock tracking enabled also becomes a stock item, with the same structure as a product variant. |
| 03 | UOM Units of measure & conversion | stock is always stored in the item's base unit. |
| 04 | RSV Reservations | the reserved quantity is written by the reservation mechanism; this PRD only defines the number and its invariant. |
| 05 | LDG Inventory ledger | every stock edit leaves a ledger line. |
| 06 | TRF Stock transfers | the incoming quantity is written by stock transfers. |
| 07 | Merchant | locations and stock items both belong to a merchant. |
Assumptions
| # | Assumption | What breaks if it is wrong |
|---|---|---|
| 01 | Each item has exactly one base unit, and all of its stock is expressed in that unit. | Stock of the same item held in two different units adds up to a meaningless number, and the low-stock warning compares a threshold against a figure on a different scale. |
| 02 | Merchants accept a system-created default location until they build their own location tree. | A merchant who rejects the auto-created location blocks every operation that touches stock until inventory is configured, and a new merchant cannot sell on day one. |
| 03 | Stock recorded at a virtual location is not real goods, so it does not count toward the physical stock total and is not used to derive availability in the real world. | A virtual location used as a real warehouse inflates the physical stock total with goods that are not on any shelf, and the stock count absorbs the whole discrepancy. |
10. Release Plan & Criteria
| Aspect | Plan |
|---|---|
| Phase | P2 (Inventory foundation) - see the URD feature catalog |
| Rollout | All merchants; no feature flag |
| Migration | Create a default location for merchants that have none; rebuild the available quantity from on-hand minus reserved before turning on the database constraint; backfill existing stock items with incoming initialized to 0 |
| Release criteria | Every merchant has exactly one default location; no stock item has an available that disagrees with on-hand minus reserved; the low-stock warning resolves all three threshold tiers correctly on sample data |
| Monitoring | Number of stock items with an unintended negative available, number of sales rejected for being out of stock, number of merchants who have built a multi-level location tree |
References
- URD: Inventory locations · Stock items & levels
- Related: Reservations & oversell prevention · Inventory ledger · Stock transfers · Units of measure & conversion
- Module: Inventory - URD
- Developer: @nx/inventory · domain model
Risks & Open Questions
| # | Risk / question | Mitigation / status |
|---|---|---|
| 01 | Concurrent stock writes could leave a stock item whose available does not match on-hand minus reserved | Enforce the invariant at the database layer; the database rejects the offending write (FR-017) |
| 02 | A merchant with a deep location tree may find branch roll-ups slow once the location count grows | Open: settle the location-count ceiling and the roll-up read strategy for chain merchants with many locations (FR-007) |
| 03 | Merchants may misuse virtual locations as if they were real warehouses, skewing the physical stock total | Open: the rule that excludes virtual-location stock from the physical total has to run before we encourage merchants to use this type; on the list screen, the type label lets the reader tell the two apart (FR-001) |
| 04 | The system default threshold may be too low or too high for some verticals | Merchants override it at the item tier or the stock-item tier (FR-013) |
| 05 | The incoming quantity only makes sense for internal transfers and is easily confused with goods ordered from a vendor but not yet delivered | The two are kept apart: incoming is fed only by stock transfers; goods ordered from a vendor belong to purchase orders (PO) |
Frequently asked questions
| # | Question | Answer |
|---|---|---|
| 01 | Why does stock have to be four numbers instead of one? | Because one number cannot answer the question that matters most: how much can I still sell. Goods on the shelf may already be promised to an order in checkout. The seller looks at available, the person doing the count looks at on-hand; the two get different numbers and both are right. |
| 02 | How is incoming different from goods ordered from a vendor? | Incoming is stock that has left the merchant's own source location and is on its way here, that is, an internal transfer. Goods ordered from a vendor but not yet delivered live in purchase orders, and they never add to incoming. |
| 03 | What are virtual locations for? | For grouping goods or planning, when those goods are not on a shelf. Stock recorded there does not count toward the merchant's physical stock total. |
| 04 | I have not set up any locations - will selling be blocked? | No. The system creates a default location the first time stock has to be recorded, and deducts stock there. |
| 05 | Where does the warning threshold come from if I set nothing? | From the system default. Setting a threshold on the item beats the default; setting one on a specific stock item beats both (FR-013). |
| 06 | Does editing on-hand directly lose the audit trail? | No. Every edit leaves a line in the inventory ledger with the before and after values, so who changed what is always traceable. The editor picks a reason for the edit, and the ledger screen reads those lines back (FR-015). |
| 07 | Does moving goods between two warehouses change inventory valuation? | No. Quantities are counted per location, while valuation is a single number for the whole merchant; a transfer only moves quantity around. |