Item & Product — the catalog spine

The whole catalog reduces to one collection: Item. Every order line, stock row, price level, and UOM conversion references an Item by itemId. An item is described by a type (margins + isInventoryItem), a category, a group, one or more UOMs, and a costing method; it can nest sub-items (variants) and own price levels and UOM conversions. The separate top-level Product entity is not the ERP item — it is a near-empty name/description record and is documented here only to dispel the name collision.

Source: BE src/modules/inventory/item · BE src/modules/inventory/item/type · BE src/modules/product · Admin src/modules/item, src/modules/product

1. Purpose & scope

The item module owns the master record for everything that can be bought, sold, transferred, manufactured, or held in stock. It is responsible for:

  • The Item schema and its lifecycle (create / update / delete, with slug + keyword derivation).
  • Sub-items (variants / bundle components) via a self-referential parentId.
  • Item types (item_types) — the classification that decides isInventoryItem, holds buy/sell margins, and (in the bullion heritage) gold-purity keys.
  • Resolve-fields that join an item to its category, group, type, accounts, UOM masters, price levels, UOM conversions, images, and ledger-derived stock balance.
  • Bulk XLSX import (parse → preview → confirm).
  • A finance-account linkage (salesAccountId, costOfSalesAccountId, inventoryAccountId) with account migration on change.

It does NOT: move stock (that's ./stock.md via orders/transfers), define UOM/price-level reference data (those are Master records — see ./categories-uom.md and ./pricing.md), or compute cost-of-sale (that's the costing service — ./pricing.md §4).


2. Data model

2.1 items — the item master

inventory/item/item.scheme.ts. Extends BaseSchema (provides _id, ref, client="zerp", companyId, branchId via tenancy, documentCode, documentDate, createdAt/By, updatedAt/By, soft-delete deleted/deletedAt). Collection items, timestamps: true.

field type req? description
ref string document number / item code (from BaseSchema; surfaced as "Code" in admin)
slug string derived from name via toSlug(); part of unique index (see below)
name string yes¹ display name. Drives slug + keywords. Unique per company (service-enforced, case-insensitive)
shortName string abbreviated name
description string free text
keywords string[] derived: helper.removeSpecialChar(name).split(" ") — used for keyword search
price number 0 sales price (item-level default; price levels override)
rate number 0 legacy rate field (bullion heritage)
cost number 0 standard unit cost; AVCO/FIFO/LIFO override at sale time
costPrice number 0 manufacturing cost-price field (parallel to cost)
stockIn number 0 denormalized running IN total — do not trust; use ledger
stockOut number 0 denormalized running OUT total — do not trust
netQuantity number 0 denormalized on-hand — do not trust; use stockBalance resolve-field
grossQuantity number 0 denormalized gross on-hand
soldBy ItemSoldByTypes QUANTITY ITEM | QUANTITY | WEIGHT — drives sub-item behaviour & sale qty source
status ItemStatusTypes ONLINE ONLINE | OFFLINE
categoryId ObjectId item_categories
typeId ObjectId item_types (decides isInventoryItem)
groupId ObjectId item_groups
uomId ObjectId legacy/primary UOM → Master(key="uom")
parentId ObjectId self-ref → parent Item (set on sub-items/variants)
salesAccountId ObjectId → finance Account (revenue)
costOfSalesAccountId ObjectId → finance Account (COGS)
inventoryAccountId ObjectId → finance Account (inventory asset)
images GraphqlFileUpload[] uploaded via the upload module (module="items")
Manufacturing
isManufactured boolean false whether produced via BOM/routing
manufacturingType ManufacturingItemType NONE NONE | RAW_MATERIAL | SUB_ASSEMBLY | FINISHED_GOOD | WIP
defaultBomId ObjectId → manufacturing BOM
defaultRoutingId ObjectId → manufacturing routing
manufacturingLeadTimeDays number 0 planning lead time
purchasingLeadTimeDays number 0 planning lead time
reorderPoint number 0 reorder trigger level (stored; replenishment logic out of catalog scope)
safetyStock number 0 safety buffer (stored)
costingMethod CostingMethod AVERAGE STANDARD | AVERAGE | FIFO | LIFO — see ./pricing.md §4
Multi-UOM
baseUomId ObjectId smallest unit; all stock is stored in this unit
salesUomId ObjectId default UOM pre-selected on sales documents
purchaseUomId ObjectId default UOM pre-selected on purchase documents
reportUomId ObjectId UOM used in stock reports
type? ItemType populated by $lookupItemType aggregation / resolve-field (not a stored column)

¹ name is GraphQL-required on create (CommonItemInput.name) and drives the unique index; it is not a @Prop({required:true}) at the Mongoose level.

Indexes & soft-delete. Unique partial index on { companyId, slug } where slug is a non-empty string and deleted ≠ true. mongoose-delete plugin (deletedAt: true).

Enums (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"
}

Aggregation joins exported from item.scheme.ts: $lookupItemType (joins item_typestype, applied in every repository read), $lookupItemGroup, $lookupParentItem, $lookupSubItems, $lookUpItem (used by child collections to join back to the item).

2.2 item_types — classification + margins

inventory/item/type/type.scheme.ts. Collection item_types. Extends BaseSchema, soft-delete.

field type description
name, name2, name3 string display names (UI prefers name2, falls back to name via resolve-field)
key string stable identity key (e.g. GOLD_NINE_ONE_SIX, SERVICE); set once, immutable on update
isInventoryItem boolean the stockable switch — true → item moves stock & needs all 3 accounts
hasAccount boolean whether the type maps to GL accounts
isMain boolean primary type flag (seed metadata)
orderNo number display sort order (drag-reorder swaps two orderNos)
marginType ItemMarginType PERCENTAGE | VALUE (default PERCENTAGE)
buyMargin / sellMargin number configurable margins
fixedBuyMargin / fixedSellMargin number fixed-value margins
defaultBuyMargin / defaultSellMargin number defaults
minusBuyMargin / minusSellMargin / minusFixedBuyMargin / minusFixedSellMargin boolean negate-margin flags
rateDisplayEnabled boolean whether live rate display is on (bullion)
branchId ObjectId tenant branch
// type/type.constants.ts
export enum ItemMarginType { PERCENTAGE = "PERCENTAGE", VALUE = "VALUE" }

ItemTypeTypes is a large gold-purity seed enum (24K GOLD_NINE_NINE_NINE_NINE … 10K GOLD_FOUR_ONE_SEVEN, plus scrap variants, SILVER, USD_MYR, XAU_USD, XAG_USD, SERVICE) with a parallel ItemTypeMapping providing { isMain, hasAccount, isInventoryItem, name, name2, name3 } per key. ItemTypeService.seed(branchId) iterates the enum and inserts any missing types. Full enum reproduced in ./categories-uom.md §5. Note: the gold-purity keys reveal zerp's bullion lineage; for a generic ERP rebuild, replace the seed enum with your own types.

Flagged — margins not consumed. The buy/sell margin fields are persisted and editable but no current catalog/order code reads them to auto-price. Treat as latent/legacy unless the order docs prove otherwise.

2.3 products — the other catalog (standalone)

product/product.schema.ts. Collection products. Extends BaseSchema, soft-delete (deletedAt, deletedBy). Fields: name, description. That's the entire schema.

This module (createProduct / updateProduct / deleteProduct / deleteManyProducts / findOneProduct / productPage) is a generic CRUD catalog with no type, category, UOM, pricing, stock, or accounts. Its resolver is @ApGqlAuthorize({ authNotRequired: true }) (auth optional). It is not used by orders, stock, or pricing. See §8 for the item↔︎product distinction.


3. API surface

3.1 Item GraphQL (item.resolver.ts)

Operation Type Input Returns Auth
itemPage Query ItemPageInput ItemPageResult @ApGqlAuthorize({ includeBranchQuery: false })
findItem Query ItemQueryInput Item (nullable) current-user only
createItem Mutation item: CreateItemInput Item @ApGqlAuthorize + audit CREATE
updateItem Mutation _id, item: UpdateItemInput Item @ApGqlAuthorize + audit UPDATE
deleteItem Mutation id Boolean audit DELETE
deleteManyItems Mutation ids: [String] Boolean audit DELETE
importItems Mutation import: ItemImportInput (file) [ItemImport] audit CREATE
confirmItemsImport Mutation import: ConfirmItemImportInput ConfirmItemImportResult {created, skipped} audit CREATE

Resolve-fields on Item (the join layer): category, group, type, salesAccount, costOfSalesAccount, inventoryAccount, uom, baseUom, salesUom, purchaseUom, reportUom, parentId (stringified), parent, subItems, priceLevels (→ ItemPriceLevelService.findByItem), uomConversions (→ ItemUOMConversionService.findByItem), images (→ upload service), price (→ ItemService.price, returns stored item.price), avg (→ average purchase price via ItemPriceService.getAverage(itemId, PurchaseInvoice)), stocks, stockId (first stock's _id), inStock(branchId) and stockBalance(branchId) (both ledger aggregations via StockService.balance; branch falls back to contextSvc.branchId). inStock returns true for non-inventory items.

ItemImport has its own resolver (ItemImportResolver) re-using cached salesAccount/type/uom/etc. when present on the parsed row, else looking them up.

ItemPageInput fields: skip, take, status, category, type, groupId, keyword, parentId, excludeSubItems, branchId, sortBy, sortOrder (SortOrder), isManufactured, manufacturingType. The repository defaults excludeSubItems to true unless explicitly false (sub-items hidden from the main listing).

CreateItemInput = CommonItemInput + subItems: [SubItemInput] + priceLevels: [UpsertItemPriceLevelInput] + uomConversions: [UpsertItemUOMConversionInput]. UpdateItemInput = PartialType(CommonItemInput) + the same three arrays. ItemQueryInput = PartialType(OmitType(CommonItemInput, ["images","description"])) + _id.

CommonItemInput carries the create/update payload: name (required), shortName, price, cost, description, categoryId, typeId, groupId, soldBy, status (required), images ([GraphQLUpload]), uomId, salesAccountId, costOfSalesAccountId, inventoryAccountId, parentId, all manufacturing fields, costingMethod, and the four multi-UOM ids (baseUomId, salesUomId, purchaseUomId, reportUomId).

@InputType() class SubItemInput {
  ref: string;            // becomes the sub-item's name AND code
  price?: number; cost?: number; quantity?: number;
  netQuantity?: number; grossQuantity?: number;
}

(The price-level / uom-conversion upsert inputs are documented in ./pricing.md and ./categories-uom.md.)

3.2 Item Type GraphQL (type/type.resolver.ts)

Operation Type Input Returns
itemTypePage Query ItemTypePageInput (skip/take/sortKey/sortValue) ItemTypePageResult
createItemType Mutation itemType: CreateItemTypeInput ItemType
updateItemType Mutation _id, itemType: UpdateItemTypeInput (omits key) ItemType
updateItemTypeOrder Mutation itemTypeOrder: UpdateItemTypeOrderInput (src/dst id+index) Boolean

Resolve-field name2 returns name2 || name. All mutations audited. CreateItemTypeInput is PartialType(ItemTypeCommonInput); UpdateItemTypeInput omits key (key is immutable).

3.3 Product GraphQL (product/product.resolver.ts)

productPage, findOneProduct, createProduct, updateProduct, deleteProduct, deleteManyProducts. Inputs are trivially name/description (+ pagination + keyword/fromDate/toDate query).

3.4 REST controllers

  • item.controller.tsGET /api/maintenance/item/download?downloadType=xlsx — exports the filtered item list to XLSX (columns: Name, Type, Category, UOM, Sales Price, Cost Price, the 3 accounts, Costing Method [AVERAGE rendered as "AVCO"], Document Date). @ApiAuthorize().
  • product/product.controller.tsGET /api/product/download?downloadType=xlsx — Name/Description/Date.

4. Business rules & calculations

4.1 Item create (ItemService.createcreateSingleItem / createWithSubItems)

  • Branch on soldBy: if subItems.length && soldBy === ITEMcreateWithSubItems (transactional); otherwise createSingleItem.
  • Duplicate-name guard: existsByName(name) (case-insensitive regex, company-scoped, excludes soft-deleted) → 409 CONFLICT "Item with name "X" already exists". Also catches Mongo dup-key (11000).
  • Derivation: slug = toSlug(name), keywords = removeSpecialChar(name).split(" ").
  • Images: each gets a fresh _id, then uploaded via FileUploadService.upload({type:"stream", module:"items", refId}). If upload throws, the just-created item is rolled back (deleted).
  • Sub-items (createWithSubItems, inside withRetryTransaction): for each SubItemInput, a child Item is created with name = ref, parentId = parent._id, inheriting categoryId, uomId, typeId, soldBy, status, the 3 accounts, branchId, createdBy. Slug/keywords derived from ref.

4.2 Item update (ItemService.update, transactional)

  • Not-found → 404. Duplicate name (excluding self) → 409.
  • Image re-upload, slug/keyword re-derivation as in create.
  • Account migration: if inventoryAccountId / costOfSalesAccountId / salesAccountId changes, AccountMigrationService.migrateItemAccount({from, to, itemId}) runs (moves existing GL postings to the new account). All migrations awaited in parallel.
  • Sub-items resync: if soldBy === ITEM && subItems provided → updateSubItems deletes all existing children and recreates them from the input (full replace, not diff).

4.3 Accounts rule (ItemService.getAccounts)

  • Non-inventory item (!type.isInventoryItem) with a salesAccountId → returns only salesAccountId (inventory + COGS null). This is how service lines skip inventory/COGS accounts.
  • Inventory item missing any of the 3 accounts → 400 "Item accounts not set".

4.4 Item price resolve-fields

  • Item.price → stored item.price (the commented-out code shows an intended rate-based override that was removed — flagged as legacy/TODO).
  • Item.avgItemPriceService.getAverage(itemId, PurchaseInvoice) (delegates to OrderService.getAverage).

4.5 Item type rules

  • create: copies any existing record for the same key via findLast({key}) then spreads the new model over it ({...model, ...last} — note last wins on overlapping keys, so re-creating an existing key effectively re-reads its values).
  • updateOrder: swaps the two records' orderNo (src.orderNo = dstIndex, dst.orderNo = srcIndex).
  • seed(branchId): inserts every ItemTypeTypes key not already present, with metadata from ItemTypeMapping, rateDisplayEnabled: true, zeroed margins.

4.6 Status / state machine

No posting lifecycle. Item.status is a simple ONLINE/OFFLINE toggle; there is no SAVED/POSTED on items. Orders carry the SAVED/POSTED lifecycle (see order doc).

4.7 Side effects & transactionality

  • Create-with-subitems and every update run inside withRetryTransaction.
  • Side effects on write: file upload (S3 via upload module), GL account migration (finance), price-level & UOM-conversion upserts (driven from the resolver, not the service — see §6), audit-trail snapshot.

5. Permissions

  • All item & item-type mutations: @ApGqlAuthorize() (JWT + access-group RBAC, ../../platform/permissions-access.md) + @AuditMeta (module item / item-type).
  • itemPage uses @ApGqlAuthorize({ includeBranchQuery: false }) — the branch filter is not auto-injected (caller passes branchId explicitly).
  • findItem and the resolve-fields are not separately permission-gated beyond the resolver-level init guard.
  • Product resolver: @ApGqlAuthorize({ authNotRequired: true }) — effectively open. (Flagged as unusually permissive; consistent with product being a vestigial module.)
  • No CASL ability checks specific to items beyond the standard authorize decorator.

6. Flows

6.1 Create item (admin → DB)

  1. Admin ItemsPage → create modal (item/components/create.tsx, Formik + Yup). Validation: name required, price required, cost required iff selected type isInventoryItem, category required, itemType required; sales/COGS/inventory accounts required iff inventory item; baseUom required iff MULTI_UOM; UOM conversion factors must be > 0 (hasInvalidUomConversionFactor). Tabs: Main, Account, UOM (MULTI_UOM), Price Level (MANAGE_PRICE_LEVELS), plus a Sub-Items table (MANAGE_SUB_ITEMS + soldBy=ITEM). Tab error indicators + "Add & New" / "Add & Save".
  2. context.saveItemCREATE_ITEM mutation (item.gql/query.ts).
  3. Resolver createItem: destructures {subItems, priceLevels, uomConversions, ...itemData}, mapUploads resolves image promises, then:
    • ItemService.create(payload, subItems) → persists item (+ children).
    • savePriceLevels(itemId, priceLevels) → for each: updatePriceLevel if _id, else createPriceLevel.
    • saveUOMConversions(itemId, uomConversions) → for each: updateConversion if _id, else createConversion.
  4. Admin context reloads via fetchItemPage(page:1, pageSize:50); toast on success.
  5. Unhappy: duplicate name → 409 toast; missing required tab fields → "Please complete required fields in: [Tabs]" + auto-navigate to first errored tab; invalid conversion factor → blocking toast.

6.2 Update item — same path via UPDATE_ITEM, plus account migration & full sub-item resync (§4.2).

6.3 Bulk import (two-step wizard)

  1. Step 1 item/import page → ApFileImportFormimportItems(file). ItemService.import: reads XLSX (XlsxUtils), trims headers, pre-resolves unique Types and Categories sequentially (creating any missing — Type created {name, key:name, isInventoryItem:true}, Category created by name), then per row resolves Accounts (exact-name match), UOM (MasterService exact name, ignoreCompanyId), and parses Sales/Cost prices, Quantity, Balance, and Costing Method ("AVCO"AVERAGE; invalid → AVERAGE). Returns a preview [ItemImport].
  2. Step 2 item/confirm-import page → editable preview grid (inline-create Type/Category, global UOM setter, per-row account selects) → confirmItemsImport(items). ItemService.confirmImport: per item — skip if no name; skip (count skipped) if existsByName; else create; dup errors counted as skipped. Returns { created, skipped }.
  3. Toast summarizes counts; redirect to item list.

6.4 Delete — deleteItem(id) (soft-delete via mongoose-delete) or deleteManyItems(ids) (loops

delete). Because quantity is ledger-derived, deleting an item does not "lose" stock math, but stock rows referencing it remain; deletion is a catalog-only soft-delete.


7. Admin UI

Routes (zerp-admin/src/pages/item, /pages/products):

  • /maintenance/itemItemsPage (list, search, filters, create/edit modal, import/export).
  • /maintenance/item/importImportItem (file upload).
  • /maintenance/item/confirm-importConfirmItemImport (editable preview + confirm).
  • /maintenance/[_id]ItemDetailPage (detail with sales/purchase/stock tabs).
  • /maintenance/typesItemTypePage (drag-reorder list, create/edit).
  • /productsProductPage (separate product CRUD).

Context methods (item/context.tsx): fetchItemPage, findItem, saveItem, createItem, updateItem, deleteItem, deleteManyItems, importItem, confirmItemImport, deletePriceLevel, deleteUOMConversion, fetchUOMConversions. State: items, totalRecords, loading, importLoading, confirmImportLoading, modal, selectedRowKeys. All mutations reload the page query.

GraphQL fragments (item/gql/fragment.ts): ItemListFragment (table), ItemFragment (full edit, incl. subItems, the four multi-UOM masters, priceLevels, uomConversions), ItemImportFragment.

Notable UX: tabbed create form with per-tab error badges; uom-sync.ts getLinkedUomFieldUpdates auto-syncs salesUom/purchaseUom/reportUom to baseUom unless explicitly overridden (see ./categories-uom.md §7); bulk-select + bulk delete; item-type drag-reorder via @dnd-kitupdateItemTypeOrder; XLSX/PDF download; feature-flag gating (MANAGE_SUB_ITEMS, MULTI_UOM, MANAGE_PRICE_LEVELS). Item table renders Cost as the AVCO avg when costingMethod === AVERAGE.


8. Item ↔︎ Product distinction

Item (inventory/item) Product (product)
Collection items products
Fields full catalog (type, category, group, UOM×5, accounts×3, costing, manufacturing, pricing) name, description only
Stock yes (via ledger) none
Pricing item price + price levels + UOM prices none
Used by orders/stock/pricing yes no
Auth required optional (authNotRequired: true)

Rule of thumb: in zerp, "the catalog" = Item. The Product module is a thin, isolated CRUD that no inventory flow depends on. Likely a stub/legacy or a generic listing surface. Documented for disambiguation; do not build inventory features on it.


9. Dependencies & integrations

  • Reads: MasterService (UOM masters), AccountService (finance accounts), StockService (ledger balance/stocks), ItemTypeService, ItemCategoryService, ItemGroupService, ItemPriceService, ItemPriceLevelService, ItemUOMConversionService, FileUploadService.
  • Writes elsewhere: finance (account migration on update), audit-trail (every mutation), S3 (images via upload module).
  • Read by: order/purchase/sales item services (UOM conversion, price resolution, cost, account lookup), manufacturing (BOM/routing refs), stock (itemId on every row), reporting.
  • Item module wires (item.module.ts): StockModule, AccountModule, ItemTypeModule, ApUploadModule, ItemPriceModule, MasterModule, ItemCategoryModule, ItemGroupModule, FileUploadModule, UserModule, ItemPriceLevelModule, ItemUOMConversionModule, ItemCostingModule.

10. Gotchas & project-specific rules

  • Denormalized quantity columns (netQuantity, stockIn, stockOut, grossQuantity) on Item are not the source of truth — use stockBalance(branchId)/inStock(branchId) (ledger).
  • name is the identity for dedupe, slug, and keyword search; it is unique per company (soft-deletes excluded). Sub-items reuse ref as their name.
  • excludeSubItems defaults true in the repository page() — variants are hidden from the main list unless the caller passes excludeSubItems: false.
  • Price levels & UOM conversions are saved by the resolver, not the service — the service only persists the base item. A direct ItemService.create call (e.g. internal/import) does not create price levels/conversions.
  • Update replaces all sub-items (delete + recreate), so child _ids are not stable across updates.
  • AVERAGE is displayed/imported as "AVCO" — the enum value is AVERAGE.
  • Gold-purity item types (ItemTypeTypes) betray bullion heritage; replace for a generic rebuild.
  • Item.cost (String in GraphQL). The Item DTO declares cost: string (default 0) while the schema stores a number — a known type quirk; consumers coerce.
  • See ./categories-uom.md for UOM/category/group detail, ./pricing.md for price levels, UOM pricing, and costing math, and ./stock.md for how items move.