Product Catalog Items and Units
Phase 6B (2026-07-18). Closes the last gap in the product catalog hierarchy: SaaS-managed item
and item-unit templates, built as children of the existing
ProductCatalogTemplate (not a new template family), plus a safe
apply flow that creates rows in a tenant's real dbo.Products/dbo.ProductUnits.
The real Product/ProductUnit model, inspected before any schema was written
Product— no Code column (Name/FNameonly,[StringLength(500)]); required FKsDepartmentId/CategoryId/BusinessTypeId/UnitOfMeasureId;Description/FDescription([StringLength(10000)]); separateBarcode(64)/InternationalBarcode(30) fields;Priceis the only price field (decimal?);ProductType/ProductNatureenums;StatusIdoptional, no seeder creates default rows.ProductUnit— no Name/Code of its own at all; a pure(Product, UnitOfMeasure, Factor, ...)tuple. App-level uniqueness is(ProductId, UnitOfMeasureId)— there is no DB-level unique constraint.- Real
ProductService.CreateAsyncauto-creates exactly one baseProductUnit(Factor=1,ValueType=Equal) matching the product's own unit, then callsCreateInventoryItemAsyncin a fully swallowed try/catch after the transaction commits (a failure there never rolls back Product/ProductUnit, is never logged). The template-apply service mirrors the base-unit auto-creation, and deliberately never callsCreateInventoryItemAsync— no stock/inventory side effects at all.
Schema (SaaS DB)
| Table | Purpose |
|---|---|
Saas.ProductCatalogTemplateItems | TemplateId FK (existing template), Code (template-internal only), CategoryCode (references a category Code in the same template), Name/FName (500 — matches real Product.Name/FName length exactly), Description/FDescription, Barcode, InternationalBarcode, UnitOfMeasureCode (the item's own base unit), ProductType, Price |
Saas.ProductCatalogTemplateItemUnits | Additional/alternate units only — ItemId FK, UnitOfMeasureCode, Factor/TransactionFactor, ValueType, Price/Cost, IsDefaultForSales/Purchase/Store |
Saas.TenantProductCatalogItemTemplateApplyLogs | Central apply log, with separate Item/ItemUnit counters |
The item's own base unit lives directly on ProductCatalogTemplateItem.UnitOfMeasureCode — apply
always auto-creates the matching base ProductUnit (Factor=1) from it. ProductCatalogTemplateItemUnit
represents only additional/alternate units beyond that base one.
Apply resolution order (fixed, six steps)
- Resolve
BusinessTypefrom the default prerequisite template for the same BusinessActivity, by Name. - Resolve
ProductDepartmentthe same way, scoped to that BusinessType. - Per item: resolve
ProductCategoryfrom this same template's own category (by Code → the tenant category's Name), scoped to the resolved Department. - Resolve
UnitOfMeasurebyUnitCode(for the item's base unit, and for every alternate unit). - Create or skip
Product. - Create or skip
ProductUnit.
BusinessType/Department are resolved once per apply request, not per item, from the default
prerequisite template — never re-derived from the resolved Category's own stored FK values (which
would create a second, potentially-diverging resolution path for the same fact). A missing dependency
at any step blocks only the affected item (or just that item's alternate unit) — reported
MissingDependency, pointing at the Prerequisites/Categories apply endpoints — never auto-created.
Idempotency
Product matched by Name scoped to the resolved ProductCategory (mirrors the category apply's own
Name-scoped-to-parent convention — the real table has no Code column). ProductUnit matched by
(ProductId, UnitOfMeasureId) — the real table's own app-level uniqueness. Force=false (default): an
existing Product is never touched. Force=true: fills only a currently-blank FName — never the match
key, never any structural field. Never deletes/deactivates/resurrects a soft-deleted Product.
CreatedItemUnitCount/TotalItemUnits count only the explicit alternate units from the wire — the
auto-created base ProductUnit is persisted but never itemized in the result list.
Documented deviation: matching Product by Name scoped to Category differs from the real
ProductService.CreateAsync's own duplicate check, which is effectively global (not category-scoped). This was a deliberate choice for symmetry with the category template's own convention — a tenant with an existing product of the same Name in a different category would not be detected as a duplicate by this apply flow.
Price and barcode — never invented
Product.Price/Barcode/InternationalBarcode are only ever set from the SaaS item template's own
fields — every seeded template leaves them blank/unset. See
Operational Boundaries for the full policy this follows.
API
Extends the existing ProductCatalogTemplatesController (api/Saas/ProductCatalogTemplates) — no new
controller:
| Route | Permission |
|---|---|
POST Items/Create / PUT Items/Update/{id} / DELETE Items/Delete/{id} | ProductCatalogTemplates.Edit |
POST ItemUnits/Create / PUT ItemUnits/Update/{id} / DELETE ItemUnits/Delete/{id} | ProductCatalogTemplates.Edit |
POST {id}/ApplyItemsToTenant/Preview / POST {id}/ApplyItemsToTenant | ProductCatalogTemplates.Apply |
POST api/internal/product-catalog-item-templates/apply (Shumoul.Api) — same internal-key bridge
mechanism. No new permission strings, no MenuClaims.json changes for items/units — the real ERP's
own ProductUnitController already reuses Product.* permissions rather than defining its own, and
this phase follows the same precedent.
Entitlement
Features.ProductsManagement — same feature key as prerequisites and categories.
Seed data
10 items across the 5 existing templates (2 per template), 2 additional item-units.
What was deliberately not done this phase
No stock quantities, no opening inventory balances, no inventory transactions, no accounting postings.
No CreateInventoryItemAsync call. No automatic Tenant Starter Kit inclusion (added separately by
Phase 6C). No new permission strings, no
MenuClaims.json changes.
