PRD: Unit of measure
| Module | Inventory (CORE-06) | PRD ID | PRD-ITM-001 |
| Status | Shipped | Owner | Inventory squad |
| Date | 2026-05-04 | Version | v1.0 |
| Packages | @nx/inventory · @nx/core · apps/client | URD | ITM · POI |
TL;DR
Gives a merchant a managed catalog of units of measure - "kg", "unit", "dozen" and their own custom units - so every inventory item and purchase-order line references a real, shared unit instead of an implicit string. Units carry a category, a conversion ratio and a base-unit chain, ship seeded with system defaults, and are editable from a client screen. The result: the unit dimension every catalog entity runs on is finally explicit, consistent, and merchant-owned.
1. Context & Problem
Inventory items are keyed by (variant, location, unit) (URD-ITM-002) and purchase-order lines accumulate by (itemType, itemId, uom) (URD-POI-001) - yet there is no managed catalog of those units. Codes like "kg", "unit" or "dozen" are implicit strings with no shared conversion ratios, no merchant-specific custom units, and no UI for a merchant to see or edit the units their catalog runs on. The Definitions §3 entry already defines a Unit of Measure as a "measurement unit with 3-level scope: system defaults → merchant → product/material," but nothing realizes it.
This makes the unit dimension fragile: two items can drift on what "box" means, ratios are duplicated everywhere they are needed, and a merchant cannot introduce units specific to their trade. A first-class Unit of Measure catalog is the prerequisite for consistent stock math, PO line accumulation, and (later) recipe/BOM unit handling.
2. Goals & Non-Goals
Goals
- A two-tier UoM catalog: seeded system defaults (
merchantId = NULL) plus merchant-scoped custom units; resolution is merchant → system, first match per code wins. - Each unit carries an i18n name/description, a grouping category (count, weight, volume, length, time, area, other), a conversion ratio, and a self-referencing base unit (
referenceId) so ratios chain instead of being duplicated. - A full unit lifecycle (ACTIVATED → DEACTIVATED → ARCHIVED), per-merchant isolation, soft-delete, and a unique
(code, merchantId)partial index. - A CRUD API for units, with the seed pre-populating the default catalog at startup.
- A client UoM management screen: create, edit and list views, a category picker, base-unit selection, and schema validation.
Non-Goals
- Cross-category conversion (weight ↔ volume) - categories exist precisely to block nonsensical conversions at the application layer, not to bridge them.
- A third product-/material-specific UoM tier - product pack-size overrides live on the product entity via base/purchase/sale UoM roles, not as a UoM row.
- Recipe/BOM unit handling - owned by the in-progress Recipes / BOM area.
- The back-office (
apps/bo) UoM page - a later, separate surface.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Unit consistency | 100% of inventory items and PO lines reference a catalog unit (not an ad-hoc string) |
| Seed coverage | Every merchant resolves the full system-default unit catalog at startup with zero gaps |
| Custom adoption | Merchants in unit-heavy trades create at least one custom unit |
| Conversion integrity | Zero cross-category conversions accepted; base-unit chains resolve without cycles |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner | Define the units their catalog runs on, including units specific to their trade |
| Inventory staff | Pick the right unit when creating items and PO lines; trust that "kg" means one thing |
| System | Seed the default unit catalog so every merchant starts with a usable set |
Core scenarios: a merchant opens the UoM screen → sees the seeded system defaults → adds a custom unit with a category, a conversion ratio and a base unit → edits or deactivates units as their catalog evolves → every inventory item and PO line picks units from this catalog.
5. User Stories
- As an owner, I want a starting set of common units to already exist, so that I can use units without configuring anything first.
- As an owner, I want to add a custom unit specific to my trade, so that my catalog speaks my units, not just the defaults.
- As an owner, I want each unit to belong to a category and chain to a base unit by a ratio, so that conversions are shared instead of re-entered everywhere.
- As inventory staff, I want to pick a unit from a managed list when creating items and PO lines, so that the unit dimension is consistent across the catalog.
- As an owner, I want to deactivate or archive a unit I no longer use, so that stale units stop appearing while history is preserved.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | A Unit of Measure entity with i18n name/description, category, status, conversion ratio, self-ref referenceId, and merchantId | URD-ITM-002 · Definitions §3 |
| FR-2 | Two-tier scoping: seeded system defaults (merchantId = NULL) + merchant-scoped units; resolution merchant → system, first match per code wins | Definitions §3 |
| FR-3 | Unique partial index on (code, merchantId) where not deleted, so a code is unique within a merchant's scope | URD-ITM-002 |
| FR-4 | Category taxonomy (count / weight / volume / length / time / area / other) with i18n labels; categories block cross-category conversion | Definitions §3 |
| FR-5 | Self-referencing base unit (referenceId) with a conversion ratio so ratios chain instead of being duplicated | Definitions §3 |
| FR-6 | Unit lifecycle ACTIVATED → DEACTIVATED → ARCHIVED, per-merchant isolation, and soft-delete | URD-ITM-005 |
| FR-7 | A CRUD API for units (create / update / list), permission-gated under the unit-of-measures subject | URD-ITM-002 |
| FR-8 | A seed pre-populates the system-default unit catalog at startup | Definitions §3 |
| FR-9 | UoM roles (base / purchase / sale) bind catalog entities to units; PO lines and items default to the item's base unit | URD-POI-005 |
| FR-10 | A client UoM management screen: create, edit, list, a category picker, base-unit selection, and schema validation | URD-ITM-002 |
Full requirement text and acceptance criteria live in the Inventory URD. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Data integrity | A code is unique within a merchant's scope via the (code, merchantId) partial index; base-unit chains must not form cycles |
| Tenancy & authz | Merchant-scoped (x-merchant-id); system defaults shared read-only; mutations gated by the unit-of-measures permission subject |
| Resolution | Two-tier lookup resolves merchant → system, first match per code wins - deterministic per merchant |
| Soft-delete | Units use soft-delete; deactivated/archived/deleted units are preserved and excluded from standard listings |
| i18n | Unit names, descriptions and category labels are bilingual ({ en, vi }) |
| Precision | Conversion ratios use float(value, 4) |
8. UX & Flows
Key screens (in apps/client): the UoM list view, create/edit screens with a category picker and a base-unit selection component, all backed by schema validation.
9. Data & Domain
| Entity | Role |
|---|---|
UnitOfMeasure | A unit row - i18n name/description, category, status, conversion ratio, self-ref referenceId, merchantId (NULL for system defaults) |
| UoM category | Grouping taxonomy (count / weight / volume / length / time / area / other) with i18n labels; bounds valid conversions |
UoM role (base / purchase / sale) | Binds a catalog entity (variant/material) to its units; PO lines default to the base unit |
| Seed migration | Pre-populates the system-default unit catalog at startup |
Conceptual only - full schema and invariants in the inventory domain model and ADR-0005 UoM two-layer.
10. Dependencies & Assumptions
Depends on
- Inventory Items (URD-ITM) - items are keyed by
(variant, location, unit); the catalog supplies that unit. - Purchase Order Items (URD-POI) - PO lines accumulate per
uomand default to the item's base unit. @nx/core- owns theUnitOfMeasureschema, category constants, UoM-role interface, and the scoped repository.
Assumptions
- The system-default unit catalog is seeded before any merchant resolves units.
- Categories are a fixed, application-level taxonomy; cross-category conversion is intentionally impossible.
- Product/material pack-size overrides are expressed through UoM roles on the variant, not as extra catalog tiers.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| A merchant code could collide with a system default | Resolution is merchant → system, first match per code wins; the merchant unit shadows the default deterministically |
| Base-unit chains could form a cycle | Self-ref referenceId must point to an existing unit; chains are validated to terminate at a base unit |
| Cross-category conversion expectations | Out of scope by design - categories block it; document as a constraint |
| Editing a unit referenced by live items/lines | Units use soft-delete and a status lifecycle; deactivate rather than hard-delete to preserve references |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P1 (foundation) - supports the ITM feature in the URD feature catalog |
| Rollout | All merchants; no feature flag |
| Migration | System-default unit catalog seeded at startup; no per-merchant data migration |
| Launch criteria | Seed resolves the full default catalog; create/edit/list verified end-to-end; unique (code, merchantId) enforced; cross-category conversion rejected |
| Monitoring | Custom-unit creation per merchant, seed-resolution completeness, conversion-rejection rate |
13. FAQ
Where do the starting units come from? A seed pre-populates a system-default catalog (merchantId = NULL) at startup, so every merchant has a usable set without configuring anything.
How does a custom unit relate to a system default with the same code? Resolution is merchant → system, first match per code wins - a merchant unit shadows the system default for that merchant.
Can I convert kilograms to litres? No - categories (weight, volume, …) exist precisely to block cross-category conversion. Conversion only chains within a category via the base-unit ratio.
Is there a third product-specific unit tier? No - product/material pack-size is expressed through base/purchase/sale UoM roles on the variant, not as an extra UoM catalog row.
Where is the UoM screen? In apps/client - a list view plus create/edit screens with a category picker and base-unit selection. The back-office page is a separate, later surface.
References
- URD: Inventory - ITM · Purchase Order Items · Definitions §3
- Related PRD: Purchase Orders · Recipes / BOM
- Module: Inventory - overview + traceability
- Developer: @nx/inventory · domain model · ADR-0005 UoM two-layer