Use Cases: Item Catalog
Module: item_catalog
Last Updated: 2026-05-10
Generated From: analysis.md
Use Case Listing
Commands
| ID |
Use Case |
Actor(s) |
Command |
Aggregate |
| UC-001 |
CreateItem |
Admin, Manager, Technologist |
CreateItemCommand |
Item |
| UC-002 |
EditItem |
Admin, Manager, Technologist |
EditItemCommand |
Item |
| UC-003 |
DeactivateItem |
Admin |
DeactivateItemCommand |
Item |
| UC-004 |
ReactivateItem |
Admin |
ReactivateItemCommand |
Item |
| UC-005 |
AssignItemToGroup |
Admin, Manager, Technologist |
AssignItemToGroupCommand |
Item |
| UC-006 |
RemoveItemFromGroup |
Admin, Manager, Technologist |
RemoveItemFromGroupCommand |
Item |
| UC-007 |
ImportItems |
Admin |
ImportItemsCommand |
Item (multiple) |
| UC-008 |
CreateItemGroup |
Admin, Manager |
CreateItemGroupCommand |
ItemGroup |
| UC-009 |
EditItemGroup |
Admin, Manager |
EditItemGroupCommand |
ItemGroup |
| UC-010 |
DeleteItemGroup |
Admin, Manager |
DeleteItemGroupCommand |
ItemGroup |
Queries
| ID |
Use Case |
Actor(s) |
Query |
Returns |
| UC-Q01 |
GetItem |
All authenticated |
GetItemQuery |
ItemDetailReadModel |
| UC-Q02 |
ListItems |
All authenticated |
ListItemsQuery |
PaginatedItemsReadModel |
| UC-Q03 |
GetImportTemplate |
Admin |
GetImportTemplateQuery |
.xlsx binary |
| UC-Q04 |
ListItemGroups |
All authenticated |
ListItemGroupsQuery |
PaginatedItemGroupsReadModel |
| UC-Q05 |
GetItemGroup |
All authenticated |
GetItemGroupQuery |
ItemGroupDetailReadModel |
| UC-Q06 |
ListItemsByGroup |
All authenticated |
ListItemsByGroupQuery |
PaginatedItemsReadModel |
Detailed Use Cases — Commands
UC-001: CreateItem
Actor: Admin, Manager, Technologist
Trigger: User submits the "New Item" form
Command
@dataclass
class CreateItemCommand:
tenant_id: UUID
created_by: UUID
code: str
name: str
item_type: ItemType
storage_unit: StorageUnit
description: str | None
tags: list[str]
group_id: UUID | None
shelf_life_days: int | None
ean: str | None
Execution Flow
| Step |
Action |
Details |
| 1 |
Check shelf_life_days applicability |
Raise ShelfLifeDaysNotApplicable if shelf_life_days is set and item_type ∉ |
| 2 |
Check code uniqueness |
item_repo.get_by_code(cmd.code, cmd.tenant_id) — raise ItemCodeAlreadyInUse if result is not None |
| 3 |
Check EAN uniqueness |
If cmd.ean is set: item_repo.get_by_ean(cmd.ean, cmd.tenant_id) — raise EANAlreadyInUse if result is not None |
| 4 |
Validate group compatibility |
If cmd.group_id is set: group_repo.get_by_id(cmd.group_id, cmd.tenant_id) — raise ItemGroupNotFound if missing; raise GroupItemTypeMismatch if group.item_type ≠ cmd.item_type |
| 5 |
Call domain factory |
item = Item.create(...) — deduplicates tags, builds Item in ACTIVE status, returns ItemCreated event |
| 6 |
Persist |
item_repo.save(item) |
| 7 |
Publish events |
event_publisher.publish(ItemCreated) |
| 8 |
Return result |
CreateItemResult(item_id=item.id) |
Command Result
@dataclass
class CreateItemResult:
item_id: UUID
Failure Paths
| Exception |
Raised When |
HTTP Status |
ShelfLifeDaysNotApplicable |
shelf_life_days set on SERVICE, PACKAGING, CONSUMABLE, RESALE, or INGREDIENT |
400 |
ItemCodeAlreadyInUse |
code exists for another item in tenant namespace |
409 |
EANAlreadyInUse |
ean already assigned to another item in the tenant |
409 |
ItemGroupNotFound |
group_id provided but not found for tenant |
404 |
GroupItemTypeMismatch |
group.item_type ≠ item.item_type |
409 |
UC-002: EditItem
Actor: Admin, Manager, Technologist
Trigger: User submits the "Edit Item" form; only fields with new values are included
Command
@dataclass
class EditItemCommand:
item_id: UUID
tenant_id: UUID
updated_by: UUID
name: str | None # None = unchanged
description: str | None # None = unchanged
tags: list[str] | None # None = unchanged; [] clears all tags
shelf_life_days: int | None # None = unchanged; use sentinel to clear
ean: str | None # None = unchanged; use sentinel to clear
Clearing optional fields: the API schema uses a sentinel pattern — {"ean": null} (explicit null in JSON) means "clear EAN", while the field being absent means "don't change". The command dataclass uses a separate clear_ean: bool and clear_shelf_life_days: bool flag pattern, or a UNSET sentinel, to distinguish these cases.
Execution Flow
| Step |
Action |
Details |
| 1 |
Load aggregate |
item_repo.get_by_id(cmd.item_id, cmd.tenant_id) — raise ItemNotFound if missing |
| 2 |
Guard system item |
item.is_system == True → raise SystemItemIsReadOnly |
| 3 |
Check shelf_life_days applicability |
If new shelf_life_days value (non-sentinel) is set: raise ShelfLifeDaysNotApplicable if item.item_type ∉ |
| 4 |
Check EAN uniqueness |
If new EAN value (non-sentinel) is set: item_repo.get_by_ean(new_ean, cmd.tenant_id) — raise EANAlreadyInUse if result exists and result.id ≠ item.id |
| 5 |
Call domain method |
events = item.edit(name, description, tags, shelf_life_days, ean, updated_by) — returns empty list if no field changed |
| 6 |
Persist (only if changed) |
If events non-empty: item_repo.save(item) |
| 7 |
Publish events |
If events non-empty: event_publisher.publish(ItemUpdated) |
| 8 |
Return result |
EditItemResult(item_id=item.id, changed=bool(events)) |
Command Result
@dataclass
class EditItemResult:
item_id: UUID
changed: bool # False when submitted values matched stored values (no-op)
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemNotFound |
item_id not found for tenant |
404 |
SystemItemIsReadOnly |
item.is_system is True |
403 |
ShelfLifeDaysNotApplicable |
shelf_life_days set on non-PRODUCT/INTERMEDIATE type |
400 |
EANAlreadyInUse |
new EAN already assigned to a different item |
409 |
UC-003: DeactivateItem
Actor: Admin
Trigger: Admin clicks "Deactivate" on an active item
Command
@dataclass
class DeactivateItemCommand:
item_id: UUID
tenant_id: UUID
deactivated_by: UUID
Execution Flow
| Step |
Action |
Details |
| 1 |
Load aggregate |
item_repo.get_by_id(cmd.item_id, cmd.tenant_id) — raise ItemNotFound |
| 2 |
Guard system item |
item.is_system == True → raise SystemItemIsReadOnly |
| 3 |
Guard state |
item.status == INACTIVE → raise InvalidItemStateTransition |
| 4 |
Check stock (Warehouse port) |
warehouse_port.get_stock_quantity(item.id, item.tenant_id) > 0 → raise ItemHasStockRemaining |
| 5 |
Check open ZPs (Manufacturing port) |
manufacturing_port.count_open_production_orders(item.id, item.tenant_id) > 0 → raise ItemHasOpenProductionOrders with count in message |
| 6 |
Check published BOM (Manufacturing port) |
manufacturing_port.count_published_bom_appearances(item.id, item.tenant_id) > 0 → raise ItemIsInPublishedBOM with count in message |
| 7 |
Check open ZSs (CRM port) |
crm_port.count_open_sales_orders(item.id, item.tenant_id) > 0 → raise ItemHasOpenSalesOrders with count in message |
| 8 |
Call domain method |
events = item.deactivate(cmd.deactivated_by) |
| 9 |
Persist |
item_repo.save(item) |
| 10 |
Publish events |
event_publisher.publish(ItemDeactivated) |
| 11 |
Return result |
DeactivateItemResult(item_id=item.id) |
All four cross-module checks (steps 4–7) are in-process synchronous calls via domain ports. This is a documented exception to the events-only inter-module rule. All four checks must pass before deactivation proceeds. If multiple conditions are violated, the first failing check determines the error (short-circuit evaluation).
Command Result
@dataclass
class DeactivateItemResult:
item_id: UUID
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemNotFound |
item_id not found for tenant |
404 |
SystemItemIsReadOnly |
is_system is True |
403 |
InvalidItemStateTransition |
item.status is already INACTIVE |
400 |
ItemHasStockRemaining |
Warehouse reports stock > 0 |
409 |
ItemHasOpenProductionOrders |
Manufacturing reports open ZPs > 0 |
409 |
ItemIsInPublishedBOM |
Manufacturing reports item in published recipe(s) |
409 |
ItemHasOpenSalesOrders |
CRM reports open ZSs > 0 |
409 |
UC-004: ReactivateItem
Actor: Admin
Trigger: Admin clicks "Reactivate" on an inactive item
Command
@dataclass
class ReactivateItemCommand:
item_id: UUID
tenant_id: UUID
reactivated_by: UUID
Execution Flow
| Step |
Action |
Details |
| 1 |
Load aggregate |
item_repo.get_by_id(cmd.item_id, cmd.tenant_id) — raise ItemNotFound |
| 2 |
Guard state |
item.status == ACTIVE → raise InvalidItemStateTransition |
| 3 |
Call domain method |
events = item.reactivate(cmd.reactivated_by) |
| 4 |
Persist |
item_repo.save(item) |
| 5 |
Publish events |
event_publisher.publish(ItemReactivated) |
| 6 |
Return result |
ReactivateItemResult(item_id=item.id) |
Command Result
@dataclass
class ReactivateItemResult:
item_id: UUID
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemNotFound |
item_id not found for tenant |
404 |
InvalidItemStateTransition |
item.status is already ACTIVE |
400 |
UC-005: AssignItemToGroup
Actor: Admin, Manager, Technologist
Trigger: User selects a group from the group picker on the item detail screen
Command
@dataclass
class AssignItemToGroupCommand:
item_id: UUID
group_id: UUID
tenant_id: UUID
updated_by: UUID
Execution Flow
| Step |
Action |
Details |
| 1 |
Load item |
item_repo.get_by_id(cmd.item_id, cmd.tenant_id) — raise ItemNotFound |
| 2 |
Guard system item |
item.is_system == True → raise SystemItemIsReadOnly |
| 3 |
Load group |
group_repo.get_by_id(cmd.group_id, cmd.tenant_id) — raise ItemGroupNotFound |
| 4 |
Check type compatibility |
group.item_type ≠ item.item_type → raise GroupItemTypeMismatch |
| 5 |
Call domain method |
item.assign_to_group(cmd.group_id) — replaces any prior group_id |
| 6 |
Persist |
item_repo.save(item) |
| 7 |
Return result |
AssignItemToGroupResult(item_id=item.id, group_id=cmd.group_id) |
Command Result
@dataclass
class AssignItemToGroupResult:
item_id: UUID
group_id: UUID
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemNotFound |
item_id not found |
404 |
SystemItemIsReadOnly |
item.is_system is True |
403 |
ItemGroupNotFound |
group_id not found |
404 |
GroupItemTypeMismatch |
group.item_type ≠ item.item_type |
409 |
UC-006: RemoveItemFromGroup
Actor: Admin, Manager, Technologist
Trigger: User clicks "Remove from group" on the item detail screen
Command
@dataclass
class RemoveItemFromGroupCommand:
item_id: UUID
tenant_id: UUID
updated_by: UUID
Execution Flow
| Step |
Action |
Details |
| 1 |
Load item |
item_repo.get_by_id(cmd.item_id, cmd.tenant_id) — raise ItemNotFound |
| 2 |
Guard system item |
item.is_system == True → raise SystemItemIsReadOnly |
| 3 |
Guard has group |
item.group_id is None → raise ItemGroupNotAssigned |
| 4 |
Call domain method |
item.remove_from_group() |
| 5 |
Persist |
item_repo.save(item) |
| 6 |
Return result |
RemoveItemFromGroupResult(item_id=item.id) |
Command Result
@dataclass
class RemoveItemFromGroupResult:
item_id: UUID
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemNotFound |
item_id not found |
404 |
SystemItemIsReadOnly |
item.is_system is True |
403 |
ItemGroupNotAssigned |
item has no group currently assigned |
400 |
UC-007: ImportItems
Actor: Admin
Trigger: Admin uploads an Excel file via the "Import" dialog
Command
@dataclass
class ImportItemsCommand:
tenant_id: UUID
created_by: UUID
file_content: bytes
filename: str
Execution Flow
| Step |
Action |
Details |
| 1 |
Pre-validate file |
Check len(file_content) <= 5_242_880 (5 MB) — raise ImportFileTooLarge; check .xlsx extension and OOXML magic bytes — raise ImportValidationFailed if invalid format |
| 2 |
Parse rows |
Read all data rows; check row count <= 500 — raise ImportTooManyRows |
| 3 |
Validate all rows (full-pass) |
For each row, validate: required fields present, enum values valid, shelf_life_days type restriction, group_name references existing group with matching item_type, code uniqueness, EAN uniqueness. Accumulate all errors — do NOT stop on first error |
| 4 |
Fail fast if any errors |
If any row has errors: raise ImportValidationFailed(errors=[{row, field, message}]) — no items are saved |
| 5 |
Create items (all-or-nothing) |
For each row: item = Item.create(...) |
| 6 |
Persist within one transaction |
item_repo.save(item) for each item inside a single DB transaction |
| 7 |
Publish events |
event_publisher.publish(ItemCreated) for each created item |
| 8 |
Return result |
ImportItemsResult(created_count=len(items)) |
The full validation pass (step 3) must complete before any write occurs. All items are saved within a single database transaction to ensure all-or-nothing semantics (step 6). Existing items are never overwritten — a code collision with an existing item is treated as a row error.
Excel Column Mapping
| Column Header |
Field |
Required |
Notes |
code |
code |
Yes |
Max 50 chars |
name |
name |
Yes |
Max 200 chars |
item_type |
item_type |
Yes |
Must match ItemType enum values |
storage_unit |
storage_unit |
Yes |
Must match StorageUnit enum values |
description |
description |
No |
Max 500 chars |
group_name |
group_id (resolved) |
No |
Looked up by (name, item_type, tenant); must exist |
tags |
tags |
No |
Comma-separated; max 20 items |
ean |
ean |
No |
Max 20 chars |
shelf_life_days |
shelf_life_days |
No |
Integer; only PRODUCT/INTERMEDIATE |
Command Result
@dataclass
class ImportItemsResult:
created_count: int
Failure Paths
| Exception |
Raised When |
HTTP Status |
ImportFileTooLarge |
file > 5 MB |
400 |
ImportTooManyRows |
more than 500 data rows |
400 |
ImportValidationFailed |
any row fails validation; includes [{row, field, message}] list |
400 |
UC-008: CreateItemGroup
Actor: Admin, Manager
Trigger: User submits the "New Group" form
Command
@dataclass
class CreateItemGroupCommand:
tenant_id: UUID
created_by: UUID
name: str
item_type: ItemType
Execution Flow
| Step |
Action |
Details |
| 1 |
Check name uniqueness |
group_repo.get_by_name_and_type(cmd.name, cmd.item_type, cmd.tenant_id) — raise ItemGroupNameAlreadyInUse if result is not None |
| 2 |
Call domain factory |
group = ItemGroup.create(name, item_type, tenant_id, created_by) |
| 3 |
Persist |
group_repo.save(group) |
| 4 |
Return result |
CreateItemGroupResult(group_id=group.id) |
Command Result
@dataclass
class CreateItemGroupResult:
group_id: UUID
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemGroupNameAlreadyInUse |
name already taken within (tenant, item_type) — case-insensitive |
409 |
UC-009: EditItemGroup
Actor: Admin, Manager
Trigger: User submits the group rename form
Command
@dataclass
class EditItemGroupCommand:
group_id: UUID
tenant_id: UUID
updated_by: UUID
name: str
Execution Flow
| Step |
Action |
Details |
| 1 |
Load aggregate |
group_repo.get_by_id(cmd.group_id, cmd.tenant_id) — raise ItemGroupNotFound |
| 2 |
Check name uniqueness |
group_repo.get_by_name_and_type(cmd.name, group.item_type, cmd.tenant_id) — raise ItemGroupNameAlreadyInUse if result exists and result.id ≠ group.id |
| 3 |
Call domain method |
group.edit(name=cmd.name, updated_by=cmd.updated_by) |
| 4 |
Persist |
group_repo.save(group) |
| 5 |
Return result |
EditItemGroupResult(group_id=group.id, changed=True, version=group.version) |
ItemGroup has no is_system flag — tenant scope is the only ownership boundary, so there is no SystemGroupIsReadOnly guard here either (same rationale as UC-010).
Command Result
@dataclass
class EditItemGroupResult:
group_id: UUID
changed: bool # False on no-op edits (empty body or same-value name)
version: int # post-save aggregate version — clients use it for subsequent PATCHes
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemGroupNotFound |
group_id not found for tenant |
404 |
ItemGroupNameAlreadyInUse |
new name taken by a different group in (tenant, item_type) |
409 |
UC-010: DeleteItemGroup
Actor: Admin, Manager
Trigger: Admin clicks "Delete" on an empty group
Command
@dataclass
class DeleteItemGroupCommand:
group_id: UUID
tenant_id: UUID
deleted_by: UUID
Execution Flow
| Step |
Action |
Details |
| 1 |
Load aggregate |
group_repo.get_by_id(cmd.group_id, cmd.tenant_id) — raise ItemGroupNotFound |
| 2 |
Check member count |
item_repo.count_by_group(cmd.group_id, cmd.tenant_id) > 0 → raise ItemGroupHasMembers |
| 3 |
Delete |
group_repo.delete(cmd.group_id, cmd.tenant_id) |
| 4 |
Return |
(void / 204) |
ItemGroup has no is_system flag — tenant scope is the only ownership boundary, so there is no SystemGroupIsReadOnly guard.
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemGroupNotFound |
group_id not found for tenant |
404 |
ItemGroupHasMembers |
member_count > 0 |
409 |
Detailed Use Cases — Queries
UC-Q01: GetItem
Actor: All authenticated roles
Trigger: User opens the item detail screen; or other module resolves item by code
Query
@dataclass
class GetItemQuery:
tenant_id: UUID
item_id: UUID | None # one of these must be provided
code: str | None
Execution Flow
| Step |
Action |
Details |
| 1 |
Load item |
By item_id → item_repo.get_by_id(); by code → item_repo.get_by_code() — raise ItemNotFound if missing |
| 2 |
Load group (optional) |
If item.group_id is set: group_repo.get_by_id(item.group_id, tenant_id) |
| 3 |
Return |
ItemDetailReadModel |
Read Model
@dataclass
class ItemGroupSummary:
group_id: UUID
name: str
@dataclass
class ItemDetailReadModel:
item_id: UUID
code: str
name: str
item_type: str
storage_unit: str
status: str
is_system: bool
description: str | None
tags: list[str]
group: ItemGroupSummary | None
shelf_life_days: int | None
ean: str | None
created_by: UUID
created_at: datetime
updated_by: UUID | None
updated_at: datetime | None
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemNotFound |
item_id or code not found |
404 |
UC-Q02: ListItems
Actor: All authenticated roles
Trigger: User opens the catalog list view; applies filters
Query
@dataclass(frozen=True)
class ItemListFilters:
q: str | None = None
item_types: tuple[ItemType, ...] = ()
status: ItemStatusFilter = ItemStatusFilter.ACTIVE # tri-state: ACTIVE | INACTIVE | ALL
group_id: UUID | None = None
@dataclass
class ListItemsQuery:
tenant_id: UUID
filters: ItemListFilters
sort_field: ItemListSortField
descending: bool
offset: int
limit: int
snapshot_at: datetime
The filter set is grouped behind ItemListFilters (a frozen domain dataclass) so it is one object across layers — the API's Pydantic ItemListFilterParams converts via .to_filters(). See conventions.md § Filter parameters.
Execution Flow
| Step |
Action |
Details |
| 1 |
Query repo |
item_repo.list_items(...) — diacritic-insensitive full-text search across name, code, description, group name, tags using unaccent + tsvector/ILIKE |
| 2 |
Return |
PaginatedItemsReadModel |
Read Model
@dataclass
class ItemSummary:
item_id: UUID
code: str
name: str
item_type: str
storage_unit: str
status: str
is_system: bool
group: ItemGroupSummary | None
@dataclass
class PaginatedItemsReadModel:
items: list[ItemSummary]
total: int
page: int
page_size: int
pages: int
UC-Q03: GetImportTemplate
Actor: Admin
Trigger: User clicks "Download Template" before importing
Query
@dataclass
class GetImportTemplateQuery:
tenant_id: UUID
Execution Flow
| Step |
Action |
Details |
| 1 |
Build template |
Construct an .xlsx workbook with a header row and one example row; column order matches the import spec |
| 2 |
Return binary |
ImportTemplateReadModel(content: bytes, filename: str) |
The template is built from a static definition in the infrastructure layer (no DB call needed).
Read Model
@dataclass
class ImportTemplateReadModel:
content: bytes
filename: str # e.g. "item_catalog_import_template.xlsx"
UC-Q04: ListItemGroups
Actor: All authenticated roles
Trigger: User opens the group management screen or the group filter dropdown
Query
@dataclass
class ListItemGroupsQuery:
tenant_id: UUID
item_type: ItemType | None
page: int # default 1
page_size: int # default 20
Execution Flow
| Step |
Action |
Details |
| 1 |
Query repo |
group_repo.list_groups(tenant_id, item_type, page, page_size) |
| 2 |
Fetch member counts |
item_repo.count_by_group() per group, or via a join in the repo implementation |
| 3 |
Return |
PaginatedItemGroupsReadModel |
Read Model
@dataclass
class ItemGroupSummaryWithCount:
group_id: UUID
name: str
item_type: str
member_count: int
@dataclass
class PaginatedItemGroupsReadModel:
groups: list[ItemGroupSummaryWithCount]
total: int
page: int
page_size: int
pages: int
UC-Q05: GetItemGroup
Actor: All authenticated roles
Trigger: User opens the group detail screen
Query
@dataclass
class GetItemGroupQuery:
group_id: UUID
tenant_id: UUID
Execution Flow
| Step |
Action |
Details |
| 1 |
Load group |
group_repo.get_by_id(group_id, tenant_id) — raise ItemGroupNotFound |
| 2 |
Count members |
item_repo.count_by_group(tenant_id, group_id) |
| 3 |
Return |
ItemGroupDetailReadModel |
Read Model
@dataclass
class ItemGroupDetailReadModel:
group_id: UUID
name: str
item_type: str
member_count: int
created_by: UUID
created_at: datetime
updated_by: UUID | None
updated_at: datetime | None
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemGroupNotFound |
group_id not found for tenant |
404 |
UC-Q06: ListItemsByGroup
Actor: All authenticated roles
Trigger: User browses items within a group on the group detail screen
Query
@dataclass
class ListItemsByGroupQuery:
group_id: UUID
tenant_id: UUID
page: int # default 1
page_size: int # default 20
Execution Flow
| Step |
Action |
Details |
| 1 |
Verify group exists |
group_repo.get_by_id(group_id, tenant_id) — raise ItemGroupNotFound |
| 2 |
Query items |
item_repo.list_by_group(tenant_id, group_id, page, page_size) |
| 3 |
Return |
PaginatedItemsReadModel (same read model as UC-Q02) |
Failure Paths
| Exception |
Raised When |
HTTP Status |
ItemGroupNotFound |
group_id not found for tenant |
404 |