Item & Product — the catalog spine
The whole catalog reduces to one collection:
Item. Every order line, stock row, price level, and UOM conversion references anItembyitemId. 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-levelProductentity is not the ERP item — it is a near-emptyname/descriptionrecord 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
Itemschema 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 decidesisInventoryItem, 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_types → type, 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.ts→GET /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.ts→GET /api/product/download?downloadType=xlsx— Name/Description/Date.
4. Business rules & calculations
4.1 Item create (ItemService.create → createSingleItem / createWithSubItems)
- Branch on
soldBy: ifsubItems.length && soldBy === ITEM→createWithSubItems(transactional); otherwisecreateSingleItem. - 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 viaFileUploadService.upload({type:"stream", module:"items", refId}). If upload throws, the just-created item is rolled back (deleted). - Sub-items (
createWithSubItems, insidewithRetryTransaction): for eachSubItemInput, a childItemis created withname = ref,parentId = parent._id, inheritingcategoryId,uomId,typeId,soldBy,status, the 3 accounts,branchId,createdBy. Slug/keywords derived fromref.
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/salesAccountIdchanges,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 && subItemsprovided →updateSubItemsdeletes 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 asalesAccountId→ returns onlysalesAccountId(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→ storeditem.price(the commented-out code shows an intended rate-based override that was removed — flagged as legacy/TODO).Item.avg→ItemPriceService.getAverage(itemId, PurchaseInvoice)(delegates toOrderService.getAverage).
4.5 Item type rules
create: copies any existing record for the samekeyviafindLast({key})then spreads the new model over it ({...model, ...last}— notelastwins 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 everyItemTypeTypeskey not already present, with metadata fromItemTypeMapping,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(moduleitem/item-type). itemPageuses@ApGqlAuthorize({ includeBranchQuery: false })— the branch filter is not auto-injected (caller passesbranchIdexplicitly).findItemand 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 withproductbeing a vestigial module.) - No CASL ability checks specific to items beyond the standard authorize decorator.
6. Flows
6.1 Create item (admin → DB)
- Admin
ItemsPage→ create modal (item/components/create.tsx, Formik + Yup). Validation:namerequired,pricerequired,costrequired iff selected typeisInventoryItem,categoryrequired,itemTyperequired; sales/COGS/inventory accounts required iff inventory item;baseUomrequired iffMULTI_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". context.saveItem→CREATE_ITEMmutation (item.gql/query.ts).- Resolver
createItem: destructures{subItems, priceLevels, uomConversions, ...itemData},mapUploadsresolves image promises, then:ItemService.create(payload, subItems)→ persists item (+ children).savePriceLevels(itemId, priceLevels)→ for each:updatePriceLevelif_id, elsecreatePriceLevel.saveUOMConversions(itemId, uomConversions)→ for each:updateConversionif_id, elsecreateConversion.
- Admin
contextreloads viafetchItemPage(page:1, pageSize:50); toast on success. - 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)
- Step 1
item/importpage →ApFileImportForm→importItems(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 (MasterServiceexact name,ignoreCompanyId), and parses Sales/Cost prices, Quantity, Balance, and Costing Method ("AVCO"→AVERAGE; invalid →AVERAGE). Returns a preview[ItemImport]. - Step 2
item/confirm-importpage → 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 (countskipped) ifexistsByName; elsecreate; dup errors counted asskipped. Returns{ created, skipped }. - 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/item→ItemsPage(list, search, filters, create/edit modal, import/export)./maintenance/item/import→ImportItem(file upload)./maintenance/item/confirm-import→ConfirmItemImport(editable preview + confirm)./maintenance/[_id]→ItemDetailPage(detail with sales/purchase/stock tabs)./maintenance/types→ItemTypePage(drag-reorder list, create/edit)./products→ProductPage(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-kit → updateItemTypeOrder; 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. TheProductmodule 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) onItemare not the source of truth — usestockBalance(branchId)/inStock(branchId)(ledger). nameis the identity for dedupe, slug, and keyword search; it is unique per company (soft-deletes excluded). Sub-items reuserefas theirname.excludeSubItemsdefaults true in the repositorypage()— variants are hidden from the main list unless the caller passesexcludeSubItems: false.- Price levels & UOM conversions are saved by the resolver, not the service — the service only persists the base item. A direct
ItemService.createcall (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. AVERAGEis displayed/imported as "AVCO" — the enum value isAVERAGE.- Gold-purity item types (
ItemTypeTypes) betray bullion heritage; replace for a generic rebuild. Item.cost(String in GraphQL). TheItemDTO declarescost: string(default 0) while the schema stores a number — a known type quirk; consumers coerce.- See
./categories-uom.mdfor UOM/category/group detail,./pricing.mdfor price levels, UOM pricing, and costing math, and./stock.mdfor how items move.