Inventory Location
1. Overview
| Property | Value |
|---|---|
| ID | FEAT-INV-LOC |
| Status | Stable |
| Owner | inventory-team |
| Depends on | Merchant (CDC source for default seed) |
InventoryLocation represents a warehouse, store, or sub-location for a merchant. Exactly one location per merchant carries isDefault=true. Locations support self-referential hierarchy (parentId) and a four-state lifecycle. The default location is auto-created when a Merchant row is committed via Debezium CDC.
2. Entity Model
Fields
| Field | Type | Required | Description |
|---|---|---|---|
merchantId | text | ✓ | Owner |
parentId | text | Self-ref for hierarchy | |
identifier | text | ✓ | Auto, LOC prefix |
isDefault | boolean | ✓ | Default false; partial unique per merchant when true |
status | text | ✓ | NEW (default) / ACTIVATED / DEACTIVATED / ARCHIVED |
name | i18n jsonb | ✓ | Display |
type | text | ✓ | InventoryLocationTypes (default PHYSICAL) |
location | jsonb | { main, sub, long, lat, postCode } |
3. Lifecycle
| From | Event | To | Guards |
|---|---|---|---|
NEW / DEACTIVATED | activate | ACTIVATED | — |
ACTIVATED | deactivate | DEACTIVATED | — |
ACTIVATED / DEACTIVATED | archive | ARCHIVED | not the merchant's default; no live InventoryStock rows |
4. Operations
InventoryLocationService (inventory-location.service.ts)
| Method | Signature | Purpose | Lines |
|---|---|---|---|
getOverview | { context, filter } | KPI cards for the Location dashboard (merchant-scoped) | 122-129 |
getList | { context, filter } | Paginated list + per-location stock rollup & attention | 131-145 |
getCount | { context, where } | Total companion for getList | 147-158 |
createAggregate | { context, data } | Create location + reconcile its sale-channel links (one TX) | 194-221 |
updateAggregate | { context, id, data } | Patch location + full-replace sale-channel links (one TX) | 224-272 |
setDefault | { context, id } | Promote to merchant's default; atomically demote other defaults | 478-494 |
activate | { context, id } | NEW/DEACTIVATED → ACTIVATED | 497-505 |
deactivate | { context, id } | ACTIVATED → DEACTIVATED | 507-519 |
archive | { context, id } | Transition to ARCHIVED with guards | 522-548 |
InventoryLocationRepository custom methods
| Method | Purpose |
|---|---|
getLocationOverview | Aggregate KPI cards: counts + total stock value + # locations needing attention. Reuses the shared stock-posture.sql fragments |
findInventoryLocations | Paginated list; per-location stock rollup + sale-channel count + attention flags (out/low/oversell via BOOL_OR) |
countInventoryLocations | Total companion for findInventoryLocations |
ensureDefaultLocation | Idempotent — find or create default for merchant; called by Merchant CDC handler |
setDefaultAtomic | Single-statement: demote other isDefault rows + promote target |
findParentChain | Traverse parentId chain up to maxDepth — used for cycle detection |
countStockReferences | Count live InventoryStock referencing this location — used by beforeDelete guard, NOT by archive |
5. REST Endpoints
| Verb | Path | Auth | Permission | Handler |
|---|---|---|---|---|
| 6× CRUD | /inventory-locations | JWT/BASIC | InventoryLocation.<crud> | merchant-scoped |
POST | /inventory-locations/aggregate | JWT/BASIC | InventoryLocation.createAggregate | createAggregate |
PATCH | /inventory-locations/:id/aggregate | JWT/BASIC | InventoryLocation.updateAggregate | updateAggregate |
GET | /inventory-locations/overview | JWT/BASIC | InventoryLocation.find | getOverview |
GET | /inventory-locations/list | JWT/BASIC | InventoryLocation.find | getList |
GET | /inventory-locations/list/count | JWT/BASIC | InventoryLocation.count | getCount |
POST | /inventory-locations/:id/default | JWT/BASIC | InventoryLocation.setDefault | setDefault |
POST | /inventory-locations/:id/activate | JWT/BASIC | InventoryLocation.activate | activate |
POST | /inventory-locations/:id/deactivate | JWT/BASIC | InventoryLocation.deactivate | deactivate |
POST | /inventory-locations/:id/archive | JWT/BASIC | InventoryLocation.archive | archive |
5.1 List & Overview metrics
All three routes require the target merchant via the where filter (the screen's merchant picker) — /overview and /list read filter[where][merchantId], /list/count reads where[merchantId]. The service requires it (400 if missing), asserts it is within the caller's scope (403 otherwise), then queries that single merchant. The per-location stock posture reuses the shared stock-posture.sql fragments, so the attention math stays single-sourced with the stock and item lists.
/list row — location fields plus summary and needAttention:
| Block | Field | Meaning |
|---|---|---|
saleChannels | total | Number of sale channels routed to this location (live SaleChannelInventoryLocation links) |
summary | itemCount | Distinct InventoryItems with a stock bucket at this location |
summary | onHand | { quantity, value } summed across the location's buckets |
summary | reserved | { quantity, value } summed across the location's buckets |
needAttention | out | Any bucket with available ≤ 0 |
needAttention | oversell | Any bucket with available < 0 (subset of out) |
needAttention | low | Any bucket with 0 < available ≤ lowStockThreshold |
Ordering accepts name / identifier / type / status / createdAt (id tiebreaker); default sort is name ASC.
/overview cards:
| Block | Field | Meaning |
|---|---|---|
counts | total / physical / simulation / active | Location counts by type / ACTIVATED status |
counts | withStock / empty | Locations holding ≥1 stock bucket vs. none (empty = total − withStock) |
stock | totalOnHand / totalValue | On-hand quantity + valuation across all the merchant's buckets |
needAttention | — | Number of locations with ≥1 bucket needing attention (out ∪ low) |
5.2 Aggregate create / update
POST /aggregate and PATCH /{id}/aggregate create/patch a location and reconcile which sale channels sell from it, in one transaction. The location write goes through the normal lifecycle hooks (NEW status, atomic isDefault, parent-cycle checks); the sale-channel links are then reconciled against SaleChannelInventoryLocation.
saleChannels[] (each { saleChannelId, priority? }) is a declarative full set:
| Verb | saleChannels | Effect |
|---|---|---|
POST | provided / omitted | The exact set of links to create (omitted = none). |
PATCH | omitted | Links left untouched. |
PATCH | provided | Becomes the exact set — listed channels upserted (priority patched), unlisted ones soft-deleted. [] removes all. |
Every saleChannelId is validated to belong to the location's merchant (400 not found, 403 cross-merchant). merchantId / identifier are immutable on update. Response: { location, saleChannels[] }.
isDefault: honored on both verbs, but only ever promotes. On PATCH, isDefault: true runs the same logic as POST /{id}/default (shared _promoteToDefault): the row must be ACTIVATED (else 400), then the flip is atomic — the merchant's other default is demoted and this one promoted in one statement. isDefault: false / omitted is a no-op (un-defaulting happens by promoting another location). status is not accepted by the aggregate — transitions go through /activate · /deactivate · /archive.
Commerce also exposes a CRUD write path for these links (sale-channel side). Both write the same
SaleChannelInventoryLocationtable — single source of truth, two entry points.
6. Events
Inbound:
| Topic | Trigger | Handler | Effect |
|---|---|---|---|
nx.seller.public.merchant (Debezium CDC) | Merchant row INSERT/UPDATE | InventoryWorkerService.handleMerchantCDC | ensureDefaultLocation(merchantId) — idempotent |
Outbound: none directly.
7. Default Location Seed Flow
Idempotent — re-delivery causes no-op.
8. Invariants
| Invariant | Enforcement |
|---|---|
Exactly one isDefault=true per merchant | Partial unique index + setDefaultAtomic |
parentId chain has no cycles | Service-level cycle detection on update |
| Cannot archive default location | Service guard in archive |
| Cannot delete location with live stock | beforeDelete guard via countStockReferences (note: archive has no such guard today) |
archive doesn't cascade to children | Children must be archived/reparented first |
9. Related Pages
- Inventory Stock — locations hold stock buckets
- Purchase Order — default location is fallback receive destination
- Architecture — Merchant CDC flow
- Domain Model