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
isInventoryItemswitch (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
Masterdocument withkey="uom", not a row in any of these collections.ItemUOMConversionandItem.{base,sales,purchase,report}UomIdonly 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
ItemCategoryStatusTypesenum 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().
isBasevsItem.baseUomId. The item's canonical base unit isItem.baseUomId(on the item master). AnItemUOMConversionrow withisBase=trueis the conversion-table representation of that base unit (factor 1). The order services compare againstitem.baseUomId, not againstisBase— 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
Itemform does not callcreateUOMConversion/updateUOMConversiondirectly — it nestsuomConversions: [UpsertItemUOMConversionInput]increateItem/updateItem, and the item resolver callssaveUOMConversions(create if no_id, else update) — see./item.md§6.
3.5 REST controllers
category/category.controller.ts→GET /api/maintenance/item-categories/download?downloadType=xlsx— exports filtered categories (columns: Name, Document Date).@ApiAuthorize().type/type.controller.tsexists 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 uppercasesname, 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 achildrenMapkeyed byparentId.toString(), recursively assemblingsubCategories. Pagination (skip/take, default take 50) is applied to the root list after tree assembly — sototalRecords= number of roots, not total nodes. seed(branchId): for every inventory item type (isInventoryItem === true), creates a category named after the type'sname2, reusing the type's_idas 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 samecodeexists → throws"Item group with code <code> already exists". (Stricter than category, which is idempotent.) - Update: 404 if not found; if
codechanges and the new code exists elsewhere → same dup error.
4.3 Item Type — create-spread, order-swap, seed
- Create (
ItemTypeService.create):findLast({ key })thencreate({ ...model, ...last }).lastwins on overlapping keys — re-creating an existingkeyeffectively 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'orderNo—src.orderNo = payload.dstIndex,dst.orderNo = payload.srcIndex. Drives drag-reorder.seed(branchId): iteratesObject.keys(ItemTypeTypes); for each key not already present, inserts a type with metadata fromItemTypeMapping[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_OZexists inItemTypeMappingbut not in theItemTypeTypesenum (so it is never seeded).GOLD_SEVEN_ZERO_EIGHT(17K) carriesname2: "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)
- Admin
CategoriesPage(item/category/page.tsx) → "Add Category" →ApModal→CreateCategory(create/Create.tsx, Formik + Yup,namerequired). context.saveCategory(id?, {name, files})→createItemCategory(no id) orupdateItemCategory.- Service uppercases
name, idempotently returns existing-by-name-or-creates, stamps image_ids. fetchCategoryPagereloads — backend re-materialises the tree; toast on success.- Unhappy: duplicate name → silently returns existing row (no error); empty name → Yup blocks submit.
7.2 Create / reorder item type (admin → DB)
- Admin
ItemTypePage(type/page.tsx) → drag-and-drop list (@dnd-kitDndContext/SortableContext, restricted to vertical axis). Columns includeisInventoryItemrendered as "INVENTORY ITEM" / "NON-INVENTORY ITEM". - Create/edit via
detail.tsx(Formik):name,key,isInventoryItem(ApSwitchInput),marginTypeselect, buy/sell margins, fixed margins, the four minus-margin toggles.context.saveItemType→createItemType(key spread-merge) orupdateItemType(key omitted). - Drag end →
context.updateItemOrder({srcId,srcIndex,dstId,dstIndex})→updateItemTypeOrder→ service swaps the twoorderNos.
7.3 Configure UOM conversions on an item (admin → nested write)
- Admin item create/edit form (
MULTI_UOM-gated UOM tab). The item form picksbaseUomand alternate UOMs;uom-sync.tsgetLinkedUomFieldUpdatesauto-syncssalesUom/purchaseUom/reportUomto followbaseUomunless the user has explicitly overridden them (a field follows base only if it is empty or currently equals the previous base).hasInvalidUomConversionFactorblocks submit if any factor≤ 0. - On submit,
uomConversionsare nested increateItem/updateItem; the item resolver fans out tocreateUOMConversion/updateUOMConversion(see./item.md§6). - Per-item standalone editing uses
useItemUOMConversionQuery(itemId)(uom-conversion/gql/query.ts):itemUOMConversionsquery +create/update/deleteUOMConversionmutations, eachrefetch()-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 nouomConversionsand no multi-UOM ids still works (factor 1, base = legacyuomId).
8. Admin UI
Routes / pages:
/maintenance/categories→CategoriesPage(search + table + create/edit modal + XLSX/PDF download)./maintenance/item/groups(group module) → group page (table + create modal, name/code/description form)./maintenance/types→ItemTypePage(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.tsx→useCategoryState:fetchCategoryPage,saveCategory,deleteCategory(+modal,filter,categories,totalRecords). Reloads page after each mutation.group/context.tsx→useItemGroupState:fetchItemGroupPage,fetchItemGroup,createItemGroup,updateItemGroup,deleteItemGroup. Optimistically returns created group so anApSelectInputcan auto-select it (inline-create flow).type/context.tsx→useItemTypeState:fetchItemTypes(take 1000 — no real pagination),saveItemType,createItemType,updateItemType,updateItemOrder.uom-conversion/gql/query.ts→useItemUOMConversionQuery(itemId):uomConversions,createConversion,updateConversion,deleteConversion(ApollouseQuery/useMutation,refetchafter 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(forseed). - UOM-conversion & price-level read
MasterService(resolve theuom/price-levelMaster). - 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_conversionsonly 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
findOnelookups are uppercased; pass names case-insensitively but expect them stored UPPER. isBase(conversion row) ≠Item.baseUomId. Order code compares againstitem.baseUomId;isBaseis the conversion table's own base marker. Keep them consistent or the factor-1 short-circuit may misfire.toBaseQuantityreturns 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).
ItemTypeTypesis gold-purity-specific — replace the enum for any non-bullion rebuild. Watch the_OZ(mapping-only, never seeded) and 17K→"GOLD 12K"name2quirks.- Type create merges existing-by-key over the input (
{...model, ...last}) — re-creating an existing key does not overwrite it with new values. MULTI_UOMgates 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.mdfor the item master,./pricing.mdfor price levels + costing, and./stock.mdfor how converted quantities become ledger rows.