Skip to main content
Version: Latest

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/FName only, [StringLength(500)]); required FKs DepartmentId/CategoryId/BusinessTypeId/UnitOfMeasureId; Description/FDescription ([StringLength(10000)]); separate Barcode(64)/InternationalBarcode(30) fields; Price is the only price field (decimal?); ProductType/ProductNature enums; StatusId optional, no seeder creates default rows.
  • ProductUnitno 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.CreateAsync auto-creates exactly one base ProductUnit (Factor=1, ValueType=Equal) matching the product's own unit, then calls CreateInventoryItemAsync in 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 calls CreateInventoryItemAsync — no stock/inventory side effects at all.

Schema (SaaS DB)

TablePurpose
Saas.ProductCatalogTemplateItemsTemplateId 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.ProductCatalogTemplateItemUnitsAdditional/alternate units only — ItemId FK, UnitOfMeasureCode, Factor/TransactionFactor, ValueType, Price/Cost, IsDefaultForSales/Purchase/Store
Saas.TenantProductCatalogItemTemplateApplyLogsCentral 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)

  1. Resolve BusinessType from the default prerequisite template for the same BusinessActivity, by Name.
  2. Resolve ProductDepartment the same way, scoped to that BusinessType.
  3. Per item: resolve ProductCategory from this same template's own category (by Code → the tenant category's Name), scoped to the resolved Department.
  4. Resolve UnitOfMeasure by UnitCode (for the item's base unit, and for every alternate unit).
  5. Create or skip Product.
  6. 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:

RoutePermission
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}/ApplyItemsToTenantProductCatalogTemplates.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.