Inventory — domain overview
The inventory domain is built on one invariant: on-hand quantity is never stored; it is always
Σ netQuantity(IN) − Σ netQuantity(OUT)over an append-onlyStockledger. Everything else in the domain — the catalog (items/products), categories, groups, units of measure, pricing, costing, orders, transfers, adjustments — either describes an item or writes a row into that ledger.
Source: BE src/modules/inventory/* (+ standalone src/modules/product) · Admin src/modules/item/*, src/modules/product
This _overview.md is the entry point for the inventory domain. It maps the entities, states the stock-ledger core idea (full detail lives in ./stock.md and the BE reference zerp-be/docs/inventory-stock-flow.md), lists every sub-module, and captures the cross-submodule flows at a high level. Movement mechanics (purchase/sales/transfer posting) live in the movement docs.
1. The two halves of inventory: catalog vs. movement
The domain splits cleanly into a catalog half (what items exist and how they're described/priced) and a movement half (how quantity and cost flow). The catalog half is owned by this doc set:
| Half | Sub-modules | Owned by |
|---|---|---|
| Catalog | item/product, type, category, group, uom-conversion, price, price-level, costing | ./item.md, ./categories-uom.md, ./pricing.md |
| Movement | stock (ledger), order (PR/PO/PIV/PRT/SQ/SO/SIV/SRT), purchase, sales, transfer, adjustment, goods-receipt, fulfillment, landed-cost, approval-threshold | ./stock.md, order/purchase/sales/transfer docs |
The catalog never moves stock. It defines the Item rows that movement documents point at, plus the reference data (UOM, price levels, types, categories) that movement documents read to convert quantities and resolve prices.
2. Entity map
┌──────────────────────┐
ItemType ◀─typeId──┤ │
(item_types: margins, │ Item ├──groupId──▶ ItemGroup (item_groups)
isInventoryItem, key) │ (items) ├──categoryId──▶ ItemCategory (item_categories, self-nesting tree)
│ │
Master(key="uom") ◀──────────┤ uomId / baseUomId / │
Master(key="uom") │ salesUomId / │
│ purchaseUomId / │
│ reportUomId │
│ ├──parentId──▶ Item (self-ref: sub-items / variants)
Account (finance) ◀──────────┤ salesAccountId / │
│ costOfSalesAccountId│
│ inventoryAccountId │
└───────────┬──────────┘
│ itemId
┌────────────────────────────────┼─────────────────────────────────┐
▼ ▼ ▼
ItemPriceLevel ItemUOMConversion ItemPrice
(item_price_levels) (item_uom_conversions) (item_prices)
itemId + priceLevelId itemId + uomId itemId, costPrice,
(Master key="price-level") + conversionFactor salesPrice (history log)
salesPrice, minQty, isDefault + salesPrice/purchasePrice, isBase
│ itemId
▼
Stock (stocks) ◀── the ledger; written by orders/transfers/adjustments
type IN|OUT · netQuantity · branchId · kind
Item(collectionitems) is the catalog spine. Everything else references it byitemId.Product(collectionproducts) is a separate, near-empty entity (name+descriptiononly) and is not the same thing asItem. See the item↔︎product distinction in./item.md.- UOM and price-level "definitions" are not their own collections — they are
Masterdocuments keyeduomandprice-level(master-data domain). The inventory-specific join tables (item_uom_conversions,item_price_levels) attach per-item factors/prices to thoseMasterrows.
3. The stock-ledger core idea (summary)
Full treatment in ./stock.md. The essentials the catalog depends on:
Every inbound/outbound of inventory is one
Stockrow.type: INadds,type: OUTremoves. On-hand =Σ netQuantity(IN) − Σ netQuantity(OUT), filtered byitemId(+branchIdfor per-warehouse).
Purchase ──▶ Order(kind=PurchaseInvoice) ─▶ OrderItem ─▶ Stock(type=IN) +qty
Sales ──▶ Order(kind=SalesInvoice) ─▶ OrderItem ─▶ Stock(type=OUT) -qty
Transfer ──▶ StockTransfer ─▶ TransferItem ─▶ Stock(OUT@from) + Stock(IN@to)
Consequences that matter to the catalog:
- The
Itemschema carriesstockIn,stockOut,netQuantity,grossQuantityfields, but these are legacy/denormalized. The authoritative quantity is the ledger aggregation surfaced via the resolve-fieldsItem.stockBalance(branchId)andItem.inStock(branchId)(see./item.md§3). Treat the stored quantity fields as untrusted unless a flow explicitly maintains them. - Only items whose
ItemType.isInventoryItem === truewrite stock. Service/non-inventory items (e.g. a labour line) skip the ledger entirely. This flag is the single switch that decides whether an item is "stockable".
4. Shared enums (catalog-relevant)
Defined in inventory/item/item.scheme.ts, inventory/item/type/type.constants.ts, inventory/inventory.constant.ts. Full tables in the per-module docs; reproduced here for one-stop reference.
// item.scheme.ts
export enum ItemStatusTypes { ONLINE = "ONLINE", OFFLINE = "OFFLINE" }
export enum ItemSoldByTypes { ITEM = "ITEM", QUANTITY = "QUANTITY", WEIGHT = "WEIGHT" }
export enum CostingMethod { STANDARD = "STANDARD", AVERAGE = "AVERAGE", FIFO = "FIFO", LIFO = "LIFO" }
export enum ManufacturingItemType {
NONE = "NONE", RAW_MATERIAL = "RAW_MATERIAL", SUB_ASSEMBLY = "SUB_ASSEMBLY",
FINISHED_GOOD = "FINISHED_GOOD", WIP = "WIP"
}
// type/type.constants.ts
export enum ItemMarginType { PERCENTAGE = "PERCENTAGE", VALUE = "VALUE" }
// (ItemTypeTypes — large gold-purity seed enum, see categories-uom.md / item.md)Stock-side enums (StockTypes, StockKindTypes, StockStatus) and order-side enums (OrderKindTypes, OrderStatusTypes, …) belong to the movement docs but are referenced throughout the catalog; see ./stock.md and the order doc.
5. Sub-module inventory (catalog focus)
BE root: src/modules/inventory. The InventoryModule itself only wires the movement sub-modules (order, transfer, landed-cost, order/shortcut); the catalog modules are imported via ItemModule.
Catalog sub-modules (this doc set)
| Sub-module | BE path | Collection | Documented in |
|---|---|---|---|
| Item | inventory/item |
items |
./item.md |
| Item Type | inventory/item/type |
item_types |
./item.md (+ enum in categories-uom) |
| Item Category | inventory/item/category |
item_categories |
./categories-uom.md |
| Item Group | inventory/item/group |
item_groups |
./categories-uom.md |
| UOM Conversion | inventory/item/uom-conversion |
item_uom_conversions |
./categories-uom.md |
| Price (history) | inventory/item/price |
item_prices |
./pricing.md |
| Price Level | inventory/item/price-level |
item_price_levels |
./pricing.md |
| Costing | inventory/item/costing |
(no collection — reads order_items) |
./pricing.md |
| Product (standalone) | product (top-level, not under inventory) |
products |
./item.md |
UOM and price-level definitions (the Master records keyed uom / price-level) live in the master-data domain — see ../master-data/ (referenced from ./categories-uom.md and ./pricing.md).
Movement sub-modules (other docs)
stock, order (+ order/shortcut, order/item), purchase (+ purchase/item), sales (+ sales/item), transfer, adjustment, goods-receipt, fulfillment, landed-cost, approval-threshold. See ./stock.md and the order/purchase/sales/transfer docs.
6. Cross-submodule flows (high level)
Movement detail belongs to the movement docs; here is how the catalog participates in each flow.
6.1 Item creation (catalog write)
- Admin item form (
zerp-admin/src/modules/item) submitscreateItem. ItemResolver.createItemsplits offsubItems,priceLevels,uomConversions, thenItemService.createpersists theItem(slug + keywords derived fromname; duplicate-name guard).- The resolver then calls
savePriceLevels()(→ItemPriceLevelrows) andsaveUOMConversions()(→ItemUOMConversionrows). See./item.md§6. - No stock is written at creation. Opening balances arrive via a purchase/adjustment document.
6.2 Purchase posting (catalog read → ledger write)
Order(kind=PurchaseInvoice)per line resolves the purchase UOM (model.uomId || item.purchaseUomId || item.baseUomId || item.uomId).ItemUOMConversionService.toBaseQuantity(itemId, qty, uomId)converts the entered quantity to base units (baseQuantity = qty × conversionFactor). See./categories-uom.md§4.- A
Stock(type=IN)row is written in base units. Cost flows per the item'scostingMethod(FIFO pushes a cost layer; see./pricing.md§4 and./stock.md).
6.3 Sales posting (catalog read → price resolve → ledger write)
Order(kind=SalesInvoice)skips non-inventory items (!item.type.isInventoryItem→ no stock).- UOM conversion to base units (sales UOM fallback chain
salesUomId → baseUomId → uomId). - Price resolution (
ItemPriceLevelService.resolvePrice): customer price level → item default level →null(caller falls back toitem.price). Resolved price is per base unit, then multiplied byconversionFactorback to the line UOM. See./pricing.md§4. - Cost of sale (
ItemCostingService.getCostForSale): STANDARD / AVERAGE / FIFO / LIFO over theorder_itemslots. See./pricing.md§4. - Availability guard, then
Stock(type=OUT)in base units.
6.4 Transfer (catalog read → two ledger writes)
- Base UOM only — alternate UOMs are rejected; convert before transferring. Two
Stockrows (OUT@from, IN@to). See./stock.md.
6.5 Import (bulk catalog write)
- Two-step wizard:
importItemsparses an XLSX into a preview (resolving Type/Category/UOM/Accounts by name, auto-creating missing Types/Categories), thenconfirmItemsImportcreates rows, skipping duplicate names. See./item.md§7.
7. Permissions
Catalog mutations are gated by @ApGqlAuthorize() (JWT + access-group RBAC; see ../../platform/permissions-access.md). Two catalog features are additionally subscription-gated via @RequireFeature (GqlFeatureGuard):
| Feature flag | Gates | Module |
|---|---|---|
MANAGE_PRICE_LEVELS |
the entire ItemPriceLevel resolver |
price-level |
MULTI_UOM |
the entire ItemUOMConversion resolver |
uom-conversion |
MANAGE_SUB_ITEMS (admin-side) |
sub-items / soldBy=ITEM UI |
item form |
The Item/ItemType/ItemCategory/ItemGroup resolvers themselves are not feature-gated. Every catalog mutation carries @AuditMeta({...}) writing to the audit trail (../../platform/audit-trail.md).
8. Gotchas (domain-wide)
Product≠Item. The top-levelproductmodule is a thin, separate catalog with onlyname/description. All ERP inventory logic hangs offItem. Do not conflate.- Quantity fields on
Itemare denormalized. TruststockBalance/inStock(ledger-derived), not the storednetQuantity/stockIn/stockOut. - UOM & price-level are Master records, not bespoke collections. The join tables only add per-item data.
isInventoryItemis the stockable switch. Non-inventory item types never touch the ledger and have relaxed account requirements (onlysalesAccountId).- Gold-domain heritage.
ItemTypecarries buy/sell margins and a large gold-purity seed enum (ItemTypeTypes) — zerp descends from a bullion/jewellery system. Margins are stored but not obviously consumed by current catalog code (flagged in./item.md). - Branch/company scoping. Items, categories, types carry
branchId/companyId(multi-tenant; see../../platform/multi-tenancy.md). Stock balances are per-branch.