Skip to content

Inventory Location

1. Overview

PropertyValue
IDFEAT-INV-LOC
StatusStable
Ownerinventory-team
Depends onMerchant (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

FieldTypeRequiredDescription
merchantIdtextOwner
parentIdtextSelf-ref for hierarchy
identifiertextAuto, LOC prefix
isDefaultbooleanDefault false; partial unique per merchant when true
statustextNEW (default) / ACTIVATED / DEACTIVATED / ARCHIVED
namei18n jsonbDisplay
typetextInventoryLocationTypes (default PHYSICAL)
locationjsonb{ main, sub, long, lat, postCode }

3. Lifecycle

FromEventToGuards
NEW / DEACTIVATEDactivateACTIVATED
ACTIVATEDdeactivateDEACTIVATED
ACTIVATED / DEACTIVATEDarchiveARCHIVEDnot the merchant's default; no live InventoryStock rows

4. Operations

InventoryLocationService (inventory-location.service.ts)

MethodSignaturePurposeLines
getOverview{ context, filter }KPI cards for the Location dashboard (merchant-scoped)122-129
getList{ context, filter }Paginated list + per-location stock rollup & attention131-145
getCount{ context, where }Total companion for getList147-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 defaults478-494
activate{ context, id }NEW/DEACTIVATED → ACTIVATED497-505
deactivate{ context, id }ACTIVATED → DEACTIVATED507-519
archive{ context, id }Transition to ARCHIVED with guards522-548

InventoryLocationRepository custom methods

MethodPurpose
getLocationOverviewAggregate KPI cards: counts + total stock value + # locations needing attention. Reuses the shared stock-posture.sql fragments
findInventoryLocationsPaginated list; per-location stock rollup + sale-channel count + attention flags (out/low/oversell via BOOL_OR)
countInventoryLocationsTotal companion for findInventoryLocations
ensureDefaultLocationIdempotent — find or create default for merchant; called by Merchant CDC handler
setDefaultAtomicSingle-statement: demote other isDefault rows + promote target
findParentChainTraverse parentId chain up to maxDepth — used for cycle detection
countStockReferencesCount live InventoryStock referencing this location — used by beforeDelete guard, NOT by archive

5. REST Endpoints

VerbPathAuthPermissionHandler
6× CRUD/inventory-locationsJWT/BASICInventoryLocation.<crud>merchant-scoped
POST/inventory-locations/aggregateJWT/BASICInventoryLocation.createAggregatecreateAggregate
PATCH/inventory-locations/:id/aggregateJWT/BASICInventoryLocation.updateAggregateupdateAggregate
GET/inventory-locations/overviewJWT/BASICInventoryLocation.findgetOverview
GET/inventory-locations/listJWT/BASICInventoryLocation.findgetList
GET/inventory-locations/list/countJWT/BASICInventoryLocation.countgetCount
POST/inventory-locations/:id/defaultJWT/BASICInventoryLocation.setDefaultsetDefault
POST/inventory-locations/:id/activateJWT/BASICInventoryLocation.activateactivate
POST/inventory-locations/:id/deactivateJWT/BASICInventoryLocation.deactivatedeactivate
POST/inventory-locations/:id/archiveJWT/BASICInventoryLocation.archivearchive

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:

BlockFieldMeaning
saleChannelstotalNumber of sale channels routed to this location (live SaleChannelInventoryLocation links)
summaryitemCountDistinct InventoryItems with a stock bucket at this location
summaryonHand{ quantity, value } summed across the location's buckets
summaryreserved{ quantity, value } summed across the location's buckets
needAttentionoutAny bucket with available ≤ 0
needAttentionoversellAny bucket with available < 0 (subset of out)
needAttentionlowAny bucket with 0 < available ≤ lowStockThreshold

Ordering accepts name / identifier / type / status / createdAt (id tiebreaker); default sort is name ASC.

/overview cards:

BlockFieldMeaning
countstotal / physical / simulation / activeLocation counts by type / ACTIVATED status
countswithStock / emptyLocations holding ≥1 stock bucket vs. none (empty = total − withStock)
stocktotalOnHand / totalValueOn-hand quantity + valuation across all the merchant's buckets
needAttentionNumber of locations with ≥1 bucket needing attention (outlow)

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:

VerbsaleChannelsEffect
POSTprovided / omittedThe exact set of links to create (omitted = none).
PATCHomittedLinks left untouched.
PATCHprovidedBecomes 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 SaleChannelInventoryLocation table — single source of truth, two entry points.

6. Events

Inbound:

TopicTriggerHandlerEffect
nx.seller.public.merchant (Debezium CDC)Merchant row INSERT/UPDATEInventoryWorkerService.handleMerchantCDCensureDefaultLocation(merchantId) — idempotent

Outbound: none directly.

7. Default Location Seed Flow

Idempotent — re-delivery causes no-op.

8. Invariants

InvariantEnforcement
Exactly one isDefault=true per merchantPartial unique index + setDefaultAtomic
parentId chain has no cyclesService-level cycle detection on update
Cannot archive default locationService guard in archive
Cannot delete location with live stockbeforeDelete guard via countStockReferences (note: archive has no such guard today)
archive doesn't cascade to childrenChildren must be archived/reparented first

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.