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-only Stock ledger. 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 (collection items) is the catalog spine. Everything else references it by itemId.
  • Product (collection products) is a separate, near-empty entity (name + description only) and is not the same thing as Item. See the item↔︎product distinction in ./item.md.
  • UOM and price-level "definitions" are not their own collections — they are Master documents keyed uom and price-level (master-data domain). The inventory-specific join tables (item_uom_conversions, item_price_levels) attach per-item factors/prices to those Master rows.

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 Stock row. type: IN adds, type: OUT removes. On-hand = Σ netQuantity(IN) − Σ netQuantity(OUT), filtered by itemId (+ branchId for 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 Item schema carries stockIn, stockOut, netQuantity, grossQuantity fields, but these are legacy/denormalized. The authoritative quantity is the ledger aggregation surfaced via the resolve-fields Item.stockBalance(branchId) and Item.inStock(branchId) (see ./item.md §3). Treat the stored quantity fields as untrusted unless a flow explicitly maintains them.
  • Only items whose ItemType.isInventoryItem === true write 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)

  1. Admin item form (zerp-admin/src/modules/item) submits createItem.
  2. ItemResolver.createItem splits off subItems, priceLevels, uomConversions, then ItemService.create persists the Item (slug + keywords derived from name; duplicate-name guard).
  3. The resolver then calls savePriceLevels() (→ ItemPriceLevel rows) and saveUOMConversions() (→ ItemUOMConversion rows). See ./item.md §6.
  4. No stock is written at creation. Opening balances arrive via a purchase/adjustment document.

6.2 Purchase posting (catalog read → ledger write)

  1. Order(kind=PurchaseInvoice) per line resolves the purchase UOM (model.uomId || item.purchaseUomId || item.baseUomId || item.uomId).
  2. ItemUOMConversionService.toBaseQuantity(itemId, qty, uomId) converts the entered quantity to base units (baseQuantity = qty × conversionFactor). See ./categories-uom.md §4.
  3. A Stock(type=IN) row is written in base units. Cost flows per the item's costingMethod (FIFO pushes a cost layer; see ./pricing.md §4 and ./stock.md).

6.3 Sales posting (catalog read → price resolve → ledger write)

  1. Order(kind=SalesInvoice) skips non-inventory items (!item.type.isInventoryItem → no stock).
  2. UOM conversion to base units (sales UOM fallback chain salesUomId → baseUomId → uomId).
  3. Price resolution (ItemPriceLevelService.resolvePrice): customer price level → item default level → null (caller falls back to item.price). Resolved price is per base unit, then multiplied by conversionFactor back to the line UOM. See ./pricing.md §4.
  4. Cost of sale (ItemCostingService.getCostForSale): STANDARD / AVERAGE / FIFO / LIFO over the order_items lots. See ./pricing.md §4.
  5. 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 Stock rows (OUT@from, IN@to). See ./stock.md.

6.5 Import (bulk catalog write)

  • Two-step wizard: importItems parses an XLSX into a preview (resolving Type/Category/UOM/Accounts by name, auto-creating missing Types/Categories), then confirmItemsImport creates 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)

  • ProductItem. The top-level product module is a thin, separate catalog with only name/description. All ERP inventory logic hangs off Item. Do not conflate.
  • Quantity fields on Item are denormalized. Trust stockBalance/inStock (ledger-derived), not the stored netQuantity/stockIn/stockOut.
  • UOM & price-level are Master records, not bespoke collections. The join tables only add per-item data.
  • isInventoryItem is the stockable switch. Non-inventory item types never touch the ledger and have relaxed account requirements (only salesAccountId).
  • Gold-domain heritage. ItemType carries 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.