Item Categories, Groups, Types & UOM — catalog reference data

These four sub-modules classify and dimension an item. Category and Group organise items for reporting; Type carries the single isInventoryItem switch (and the bullion margin/purity heritage); UOM-conversion attaches per-item multi-unit factors so an order line entered in any unit can be converted to the item's base unit before stock and GL are written. The whole UOM model reduces to one number per (item, UOM): conversionFactor = how many base units in 1 of this UOM, and one operation: baseQuantity = enteredQuantity × conversionFactor.

Source: BE src/modules/inventory/item/{category,group,type,uom-conversion} · Admin src/modules/item/{category,group,type,uom-conversion} · UOM/price-level definitions live in src/modules/master (Master(key="uom"))

1. Purpose & scope

This doc owns four catalog reference entities referenced by every Item and every order line:

Entity Collection Role
Item Category item_categories self-nesting tree of categories (parentId) for grouping/reporting
Item Group item_groups flat, code-keyed grouping (code unique)
Item Type item_types classification holding isInventoryItem (the stockable switch), GL-account flag, and buy/sell margins; seeded from a gold-purity enum
Item UOM Conversion item_uom_conversions per-item join to a UOM Master, carrying conversionFactor, isBase, and optional per-UOM prices

It does NOT: define the UOM/price-level master vocabulary (those are Master records — see ../master-data/), move stock (see ./stock.md), resolve sales prices (see ./pricing.md), or own the Item master itself (see ./item.md).

Key non-obvious fact: A "UOM" (PCS, CTN, KG) is a Master document with key="uom", not a row in any of these collections. ItemUOMConversion and Item.{base,sales,purchase,report}UomId only point at those masters and add per-item conversion factors.


2. Data model

2.1 item_categories — self-nesting category tree

inventory/item/category/category.schema.ts. Extends BaseSchema, mongoose-delete (deletedAt), timestamps: true.

field type req? description
name string yes display name; uppercased by the service on create/update
parentId ObjectId self-ref → parent ItemCategory; absent ⇒ root node
branchId ObjectId tenant branch scope
images ItemCategoryImage[] { _id, uri, type }; each gets a fresh _id on write
export enum ItemCategoryStatusTypes { ACTIVE = "active", IN_ACTIVE = "IN_ACTIVE" }

The ItemCategoryStatusTypes enum is registered in GraphQL but no status field exists on the schema — it is declared and unused. There is no soft status on categories.

Tree shape. Categories form a hierarchy via parentId. The service materialises the tree in memory (groupCategories) rather than storing nesting — see §4.1. The GraphQL ItemCategory exposes a recursive subCategories: [ItemCategory] built from parentId. There is no enforced depth limit.

2.2 item_groups — flat code-keyed group

inventory/item/group/group.schema.ts. Extends BaseSchema, soft-delete, timestamps.

field type req? description
name string yes display name
code string yes unique: true at the Mongoose level + service dup-guard
description string free text

The GraphQL type adds a resolve-time items?: Item[] joined via lookUpItemGroup (items where groupId == group._id). Groups are not branch-scoped on the schema (no branchId prop).

2.3 item_types — classification + margins + purity heritage

inventory/item/type/type.scheme.ts. Extends BaseSchema, soft-delete, timestamps. This is the single most consequential reference entity because isInventoryItem decides whether an item moves stock.

field type description
name, name2, name3 string display names. UI prefers name2 (falls back to name via resolve-field)
key string stable identity (e.g. GOLD_NINE_ONE_SIX, SERVICE); immutable on update (UpdateItemTypeInput omits it)
isInventoryItem boolean the stockable switch — true ⇒ item writes stock and needs all 3 GL 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 (default) | VALUE
buyMargin / sellMargin number (def 0) configurable margins
fixedBuyMargin / fixedSellMargin number (def 0) fixed-value margins
defaultBuyMargin / defaultSellMargin number (def 0) defaults
minusBuyMargin / minusSellMargin / minusFixedBuyMargin / minusFixedSellMargin boolean negate-margin flags
rateDisplayEnabled boolean live-rate display toggle (bullion)
branchId ObjectId tenant branch
// type/type.constants.ts
export enum ItemMarginType { PERCENTAGE = "PERCENTAGE", VALUE = "VALUE" }

Flagged — margins not consumed by current order code. All buy/sell margin fields persist and are editable in the admin type form, but no catalog or order-posting code reads them to auto-price. Treat as latent/legacy (consistent with ./item.md §2.2).

2.4 item_uom_conversions — per-item multi-UOM factors

inventory/item/uom-conversion/uom-conversion.schema.ts. Extends BaseSchema, soft-delete, timestamps. One row per (item, alternate UOM).

@ApSchema({ collection: "item_uom_conversions", timestamps: true })
export class ItemUOMConversion extends BaseSchema {
  itemId:           ObjectId;  // REQUIRED — the product
  uomId:            ObjectId;  // REQUIRED — → Master(key="uom") (e.g. CTN, BOX, PCS)
  conversionFactor: number;    // REQUIRED, default 1 — how many BASE units = 1 of this UOM
  isBase:           boolean;   // default false — true for the base unit (factor 1); only one per item
  salesPrice:       number;    // default 0 — optional per-UOM sales price
  purchasePrice:    number;    // default 0 — optional per-UOM purchase price
  isActive:         boolean;   // default true — only active rows are used by toBaseQuantity()
}
field type req? default description
itemId ObjectId yes items
uomId ObjectId yes Master(key="uom")
conversionFactor number yes 1 base units per 1 of this UOM (e.g. CTN=24 ⇒ 1 CTN = 24 PCS)
isBase boolean false marks the base unit; at most one per item (service-enforced)
salesPrice number 0 optional UOM-specific sales price (0 ⇒ "not configured")
purchasePrice number 0 optional UOM-specific purchase price
isActive boolean true inactive rows are ignored by conversion/price resolution

Aggregation joins (uom-conversion.schema.ts): $lookupItemFromUOM (→ item), $lookupUOMMaster (→ uom from masters). Used by the repository page().

isBase vs Item.baseUomId. The item's canonical base unit is Item.baseUomId (on the item master). An ItemUOMConversion row with isBase=true is the conversion-table representation of that base unit (factor 1). The order services compare against item.baseUomId, not against isBase — see §4.


3. API surface

3.1 Item Category GraphQL (category/category.resolver.ts, @ApGqlAuthorize())

Operation Type Input Returns Notes
itemCategoryPage Query ItemCategoryPageInput (skip,take,keyword,branchId,status,isRoot) ItemCategoryPageResult {totalRecords, data} returns the tree (roots with nested subCategories), paginated in memory
findItemCategory Query ItemCategoryQueryInput (name) ItemCategory single by name
createItemCategory Mutation category: CreateItemCategoryInput ItemCategory audit CREATE
updateItemCategory Mutation id, category: UpdateItemCategoryInput ItemCategory audit UPDATE
deleteItemCategory Mutation id Boolean audit DELETE (soft)

CommonItemCategoryInput = name, parentId?, branchId?, files?: [ItemCategoryImageInput] (base64Str, filename, filetype). Create* and Update* are both bare aliases of CommonItemCategoryInput.

3.2 Item Group GraphQL (group/group.resolver.ts, @ApGqlAuthorize())

Operation Type Input Returns
itemGroupPage Query ItemGroupPageInput (skip,take,keyword?) ItemGroupPageResult
itemGroup Query id ItemGroup (joined with items)
createItemGroup Mutation group: CreateItemGroupInput (name,code,description?) ItemGroup
updateItemGroup Mutation id, group: UpdateItemGroupInput (PartialType) ItemGroup
deleteItemGroup Mutation id Boolean

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

Operation Type Input Returns
itemTypePage Query ItemTypePageInput (skip,take,sortKey?,sortValue?) ItemTypePageResult
createItemType Mutation itemType: CreateItemTypeInput (PartialType(ItemTypeCommonInput)) ItemType
updateItemType Mutation _id, itemType: UpdateItemTypeInput (omits key) ItemType
updateItemTypeOrder Mutation itemTypeOrder: UpdateItemTypeOrderInput (srcId,srcIndex,dstId,dstIndex) Boolean

Resolve-field name2 returns name2 || name. All mutations audited (module item-type).

3.4 Item UOM Conversion GraphQL (uom-conversion/uom-conversion.resolver.ts)

Resolver is @ApGqlAuthorize() + @UseGuards(GqlFeatureGuard) + @RequireFeature("MULTI_UOM") — the entire resolver is subscription-feature-gated.

Operation Type Input Returns Permission
itemUOMConversionPage Query ItemUOMConversionPageInput (itemId,skip,take) ItemUOMConversionPageResult MULTI_UOM
itemUOMConversions Query itemId [ItemUOMConversion] (active only) MULTI_UOM
convertToBaseQuantity Query itemId, quantity, uomId ItemUOMConversionResult {baseQuantity, conversionFactor} MULTI_UOM
createUOMConversion Mutation input: CreateItemUOMConversionInput ItemUOMConversion MULTI_UOM + audit CREATE
updateUOMConversion Mutation id, input: UpdateItemUOMConversionInput ItemUOMConversion MULTI_UOM + audit UPDATE
deleteUOMConversion Mutation id Boolean MULTI_UOM + audit DELETE

Resolve-field uom joins the Master record by uomId (MasterService.findById). convertToBaseQuantity is a read-only helper so the frontend can show conversion hints (e.g. "5 CTN = 120 PCS").

# schema.gql (generated)
input CreateItemUOMConversionInput {
  itemId: String!
  uomId: String!
  conversionFactor: Float!
  isBase: Boolean = false
  salesPrice: Float = 0
  purchasePrice: Float = 0
  isActive: Boolean = true
}
input UpdateItemUOMConversionInput {
  conversionFactor: Float
  isBase: Boolean
  salesPrice: Float
  purchasePrice: Float
  isActive: Boolean
}
type ItemUOMConversionResult { baseQuantity: Float!  conversionFactor: Float! }

Note: the Item form does not call createUOMConversion/updateUOMConversion directly — it nests uomConversions: [UpsertItemUOMConversionInput] in createItem/updateItem, and the item resolver calls saveUOMConversions (create if no _id, else update) — see ./item.md §6.

3.5 REST controllers

  • category/category.controller.tsGET /api/maintenance/item-categories/download?downloadType=xlsx — exports filtered categories (columns: Name, Document Date). @ApiAuthorize().
  • type/type.controller.ts exists for item-type export. Group has no controller.

4. Business rules & calculations

4.1 Category — uppercase, idempotent create, in-memory tree

  • Create (ItemCategoryService.create): looks up an existing category by { name: name.toUpperCase(), branchId }. If found, returns the existing row (idempotent — never throws on duplicate). Otherwise uppercases name, stamps fresh _ids on images, persists.
  • Update: 404 if not found; uppercases name; re-stamps image _ids.
  • Tree build (page()groupCategories): fetches all categories (findAll, sorted by name) so the full hierarchy is available regardless of sort, then builds a childrenMap keyed by parentId.toString(), recursively assembling subCategories. Pagination (skip/take, default take 50) is applied to the root list after tree assembly — so totalRecords = number of roots, not total nodes.
  • seed(branchId): for every inventory item type (isInventoryItem === true), creates a category named after the type's name2, reusing the type's _id as the category _id. (Wrapped in a swallowed try/catch.) This auto-derives a starter category set from the seeded types.

4.2 Group — unique code guard

  • Create (ItemGroupService.create): if a group with the same code exists → throws "Item group with code <code> already exists". (Stricter than category, which is idempotent.)
  • Update: 404 if not found; if code changes and the new code exists elsewhere → same dup error.

4.3 Item Type — create-spread, order-swap, seed

  • Create (ItemTypeService.create): findLast({ key }) then create({ ...model, ...last }). last wins on overlapping keys — re-creating an existing key effectively re-reads the prior record's values over the new input. (Subtle: this is a merge-with-existing, not a plain insert.)
  • updateOrder(payload): swaps the two records' orderNosrc.orderNo = payload.dstIndex, dst.orderNo = payload.srcIndex. Drives drag-reorder.
  • seed(branchId): iterates Object.keys(ItemTypeTypes); for each key not already present, inserts a type with metadata from ItemTypeMapping[key] (isInventoryItem, hasAccount, isMain, name, name2, name3), orderNo = enum index, zeroed margins, rateDisplayEnabled: true, the branch.

4.4 UOM conversion — the math (the core of this doc)

The single conversion primitive, used by purchase/sales item services before writing stock or GL:

// uom-conversion.service.ts → toBaseQuantity(itemId, quantity, fromUomId)
const conversion = await this.findOne({ itemId, uomId: fromUomId, isActive: true });
if (!conversion || conversion.isBase)
  return { baseQuantity: quantity, conversionFactor: 1 };   // base or no record → as-is (no data loss)
return {
  baseQuantity:     quantity * conversion.conversionFactor, // ← the conversion
  conversionFactor: conversion.conversionFactor,
};

Quantity conversion: baseQuantity = enteredQuantity × conversionFactor. Rate conversion (toBaseRate(uomPrice, factor)): baseRate = uomPrice / factor (factor 0 or 1 ⇒ unchanged). This keeps GL revenue consistent: baseRate × baseQuantity = uomPrice × enteredQuantity.

Worked examples (verbatim from uom-conversion.service.spec.ts):

Entered UOM factor baseQuantity note
5 CTN 24 120 5 × 24 (1 CTN = 24 PCS)
1.5 CTN 24 36 fractional supported
3 BOX 10 30 3 × 10
10 PCS (base) 10 base unit → factor 1, unchanged
7 CTN (no record) 7 safe fallback, factor 1

Rate / accounting correctness (sell 1 CTN = 24 PCS @ RM 240 total):

toBaseQuantity(item, 1, CTN) → baseQuantity = 24   (stock deducts 24 PCS)
toBaseRate(240, 24)          → baseRate     = 10    (RM 10/PCS)
baseRate × baseQuantity = 10 × 24 = 240             ✓ revenue matches the invoice
COGS = unitCost × baseQuantity = 8 × 24 = 192       ✓ full carton cost

Single-base invariant (createConversion / updateConversion): if input.isBase is true, clearBase(itemId) first flips every existing isBase:true row to false — so at most one base UOM per item.

Validation (validateConversionFactor): a provided conversionFactor must be a finite number > 0, else 400 "UOM conversion factor must be greater than 0". (Undefined/null is allowed through — only explicitly-set bad values throw.)

Per-UOM price resolution (used by ./pricing.md): resolveUOMSalesPrice(itemId, uomId) returns the row's salesPrice only if > 0, else null (caller falls back to item/price-level price). resolveUOMPurchasePrice is the symmetric purchase variant.

4.5 Status / state machine

None of these four entities has a posting lifecycle (no SAVED/POSTED). Categories/groups/types/conversions are plain CRUD reference data. isActive on UOM conversions is a soft on/off, not a state machine.

4.6 Side effects & transactionality

These are reference-data writes — no GL legs, no stock rows. Category/type seeds run at company/branch bootstrap. Every mutation writes an audit-trail snapshot (@AuditMeta, ../../platform/audit-trail.md). All four use soft-delete (mongoose-delete), so deleted rows are excluded from reads automatically.


5. Item Type seed enum (ItemTypeTypes + ItemTypeMapping)

type/type.constants.ts. The seed enum betrays zerp's bullion/jewellery lineage — gold-purity keys by karat, scrap variants, bullion bar/tael weights, plus silver, FX pairs, and a generic SERVICE. For a generic ERP rebuild, replace this enum with your own type vocabulary.

export enum ItemTypeTypes {
  // 24K
  GOLD_NINE_NINE_NINE_NINE, GOLD_NINE_NINE_NINE_NINE_SCRAP, GOLD_NINE_NINE_NINE_NINE_1KG,
  GOLD_NINE_NINE_NINE_NINE_100G, GOLD_NINE_NINE_NINE_NINE_50G, GOLD_NINE_NINE_NINE_NINE_1TAEL,
  // 23.5K
  GOLD_NINE_NINE_NINE,
  // 22K
  GOLD_NINE_ONE_SIX, GOLD_NINE_ONE_SIX_SCRAP,
  // 21K
  GOLD_EIGHT_SEVEN_FIVE, GOLD_EIGHT_SEVEN_FIVE_SCRAP,
  // 20K
  GOLD_EIGHT_THREE_FIVE, GOLD_EIGHT_THREE_FIVE_SCRAP,
  // 19K
  GOLD_SEVEN_NINE_TWO, GOLD_SEVEN_NINE_TWO_SCRAP,
  // 18K
  GOLD_SEVEN_FIVE_ZERO, GOLD_SEVEN_FIVE_ZERO_SCRAP,
  // 17K
  GOLD_SEVEN_ZERO_EIGHT, GOLD_SEVEN_ZERO_EIGH_TSCRAP,
  // 16K
  GOLD_SIX_SIX_SEVEN, GOLD_SIX_SIX_SEVEN_SCRAP,
  // 14K
  GOLD_FIVE_EIGHT_THREE, GOLD_FIVE_EIGHT_THREE_SCRAP,
  // 12K
  GOLD_FIVE_ZERO, GOLD_FIVE_ZERO_SCRAP,
  // 10K
  GOLD_FOUR_ONE_SEVEN, GOLD_FOUR_ONE_SEVEN_SCRAP,
  SILVER, USD_MYR, XAU_USD, XAG_USD, SERVICE,
}

ItemTypeMapping[key] supplies { isMain?, hasAccount?, isInventoryItem?, name, name2, name3? } per key. Selected mappings showing the isInventoryItem switch in action:

key name name2 isInventoryItem hasAccount
GOLD_NINE_NINE_NINE_NINE GOLD: 999.9 GOLD 24K true true
GOLD_NINE_NINE_NINE_NINE_1KG Gold : 1 Kilo 999.9 (MYR) GOLD 24K 1KG false false
GOLD_NINE_ONE_SIX GOLD: 916 GOLD 22K true true
SILVER SILVER SILVER false true
USD_MYR / XAU_USD / XAG_USD (FX rate display) (unset) (unset)
SERVICE SERVICE SERVICE (unset → falsy) (unset)

Mapping quirks (verbatim in source, do not "fix" silently): GOLD_NINE_NINE_NINE_NINE_OZ exists in ItemTypeMapping but not in the ItemTypeTypes enum (so it is never seeded). GOLD_SEVEN_ZERO_EIGHT (17K) carries name2: "GOLD 12K" (a copy/paste error — its scrap variant correctly reads "GOLD 17K Scrap").

The isInventoryItem column is the only seed datum the stock engine cares about: types where it is true write stock ledger rows; false/unset types (FX pairs, bullion bars, services) never touch the ledger and have relaxed account requirements (see ./item.md §4.3).


6. Permissions

Resolver Decorators Feature gate
Category / Group / Type @ApGqlAuthorize() + @AuditMeta
UOM Conversion @ApGqlAuthorize() + @AuditMeta @RequireFeature("MULTI_UOM") via GqlFeatureGuard

Admin buttons additionally check access-group actions, e.g. category create uses USER_ACCESS.INVENTORY.ACTIONS.CREATE_CATEGORIES. See ../../platform/permissions-access.md and ../../platform/audit-trail.md. The MULTI_UOM feature flag is subscription-driven (see ./_overview.md §7).


7. Flows

7.1 Create category (admin → tree)

  1. Admin CategoriesPage (item/category/page.tsx) → "Add Category" → ApModalCreateCategory (create/Create.tsx, Formik + Yup, name required).
  2. context.saveCategory(id?, {name, files})createItemCategory (no id) or updateItemCategory.
  3. Service uppercases name, idempotently returns existing-by-name-or-creates, stamps image _ids.
  4. fetchCategoryPage reloads — backend re-materialises the tree; toast on success.
  5. Unhappy: duplicate name → silently returns existing row (no error); empty name → Yup blocks submit.

7.2 Create / reorder item type (admin → DB)

  1. Admin ItemTypePage (type/page.tsx) → drag-and-drop list (@dnd-kit DndContext/SortableContext, restricted to vertical axis). Columns include isInventoryItem rendered as "INVENTORY ITEM" / "NON-INVENTORY ITEM".
  2. Create/edit via detail.tsx (Formik): name, key, isInventoryItem (ApSwitchInput), marginType select, buy/sell margins, fixed margins, the four minus-margin toggles. context.saveItemTypecreateItemType (key spread-merge) or updateItemType (key omitted).
  3. Drag endcontext.updateItemOrder({srcId,srcIndex,dstId,dstIndex})updateItemTypeOrder → service swaps the two orderNos.

7.3 Configure UOM conversions on an item (admin → nested write)

  1. Admin item create/edit form (MULTI_UOM-gated UOM tab). The item form picks baseUom and alternate UOMs; uom-sync.ts getLinkedUomFieldUpdates auto-syncs salesUom/purchaseUom/reportUom to follow baseUom unless the user has explicitly overridden them (a field follows base only if it is empty or currently equals the previous base). hasInvalidUomConversionFactor blocks submit if any factor ≤ 0.
  2. On submit, uomConversions are nested in createItem/updateItem; the item resolver fans out to createUOMConversion/updateUOMConversion (see ./item.md §6).
  3. Per-item standalone editing uses useItemUOMConversionQuery(itemId) (uom-conversion/gql/query.ts): itemUOMConversions query + create/update/deleteUOMConversion mutations, each refetch()-ing after.

7.4 Order-time conversion (read → ledger write) — purchase & sales

This is where UOM conversions are actually consumed (full posting in ./stock.md; price half in ./pricing.md):

Purchase (purchase/item/item.service.ts):

const uomId = model.uomId || itm.purchaseUomId || itm.baseUomId || itm.uomId;  // fallback chain
if (uomId && uomId.toString() !== itm.baseUomId?.toString()) {
  const conv = await uomConversionSvc.toBaseQuantity(itemId, uomQuantity, uomId);
  model.netQuantity   = conv.baseQuantity;            // stored in BASE units (for stock + costing)
  model.conversionFactor = conv.conversionFactor;
  model.grossQuantity = displayGrossQuantity * conv.conversionFactor;
}                                                      // else factor 1 (already base)
model.uomId = uomId; model.uomQuantity = uomQuantity;

Sales (sales/item/item.service.ts): identical shape, fallback chain model.uomId || itm.salesUomId || itm.baseUomId || itm.uomId. After converting netQuantity to base units it resolves the price (price-level, §4 of ./pricing.md) and sets model.rate = resolvedPrice × conversionFactor (price-level prices are per base unit, scaled back up to the line UOM).

Mental model for ports: entered UOM may differ from the base unit; convert qty up (× factor) and rate down (÷ factor) so the line amount is invariant. Stock and costing always operate in base units. The fallback chain means an item with no uomConversions and no multi-UOM ids still works (factor 1, base = legacy uomId).


8. Admin UI

Routes / pages:

  • /maintenance/categoriesCategoriesPage (search + table + create/edit modal + XLSX/PDF download).
  • /maintenance/item/groups (group module) → group page (table + create modal, name/code/description form).
  • /maintenance/typesItemTypePage (drag-reorder list + create/edit detail).
  • UOM conversions have no standalone page — they live in the item form's UOM tab.

Contexts & methods:

  • category/context.tsxuseCategoryState: fetchCategoryPage, saveCategory, deleteCategory (+ modal, filter, categories, totalRecords). Reloads page after each mutation.
  • group/context.tsxuseItemGroupState: fetchItemGroupPage, fetchItemGroup, createItemGroup, updateItemGroup, deleteItemGroup. Optimistically returns created group so an ApSelectInput can auto-select it (inline-create flow).
  • type/context.tsxuseItemTypeState: fetchItemTypes (take 1000 — no real pagination), saveItemType, createItemType, updateItemType, updateItemOrder.
  • uom-conversion/gql/query.tsuseItemUOMConversionQuery(itemId): uomConversions, createConversion, updateConversion, deleteConversion (Apollo useQuery/useMutation, refetch after writes).

Notable UX: category create is idempotent (no dup error); group inline-create returns the row for select auto-selection; type list is drag-reorderable (@dnd-kit); UOM tab is MULTI_UOM-gated and validates factors > 0; linked sales/purchase/report UOMs auto-follow base UOM via uom-sync.ts.


9. Dependencies & integrations

  • Category reads ItemTypeService (for seed).
  • UOM-conversion & price-level read MasterService (resolve the uom / price-level Master).
  • Item type / category / group are read by the item module (resolve-fields, import auto-create) and by purchase/sales item services (UOM conversion at posting). See ./item.md §9.
  • All four are wired into ItemModule (item.module.ts): ItemTypeModule, ItemCategoryModule, ItemGroupModule, ItemUOMConversionModule.
  • Emits audit events; uses S3 only for category images.

10. Gotchas & project-specific rules

  • A "UOM" is a Master(key="uom") record, not a row here. item_uom_conversions only attaches per-item factors/prices to those masters. Same for price levels (Master(key="price-level")).
  • Category create is idempotent, group create throws on duplicate code — asymmetric behaviour.
  • Category names and findOne lookups are uppercased; pass names case-insensitively but expect them stored UPPER.
  • isBase (conversion row) ≠ Item.baseUomId. Order code compares against item.baseUomId; isBase is the conversion table's own base marker. Keep them consistent or the factor-1 short-circuit may misfire.
  • toBaseQuantity returns input unchanged (factor 1) when no record exists — a safe no-loss fallback, but it means a missing conversion silently treats the entered qty as base units.
  • Item-type margins are stored but unused by current pricing/order code (latent bullion feature).
  • ItemTypeTypes is gold-purity-specific — replace the enum for any non-bullion rebuild. Watch the _OZ (mapping-only, never seeded) and 17K→"GOLD 12K" name2 quirks.
  • Type create merges existing-by-key over the input ({...model, ...last}) — re-creating an existing key does not overwrite it with new values.
  • MULTI_UOM gates the whole UOM-conversion resolver — without the subscription feature, conversions cannot be queried or written, and order lines fall back to base-unit-only behaviour.
  • See ./item.md for the item master, ./pricing.md for price levels + costing, and ./stock.md for how converted quantities become ledger rows.