Skip to content

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_iditem_repo.get_by_id(); by codeitem_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