Domain Model: Item Catalog¶
Module: item_catalog Last Updated: 2026-05-10 Generated From: analysis.md
1. Aggregates¶
Item (Aggregate Root)¶
Identity: id: UUID
Entities within: none — Item is a flat aggregate; group membership is a plain FK reference to ItemGroup, not a nested entity.
Invariants:
- code is immutable after creation and reserved within the tenant namespace even after deactivation
- item_type is immutable after creation
- storage_unit is immutable after creation
- ean is unique within the tenant (enforced at repo level)
- shelf_life_days may only be set on item_type ∈ {PRODUCT, INTERMEDIATE}
- tags are deduplicated case-insensitively; maximum 20 tags, each max 50 chars
- System items (is_system = True) are read-only for tenant actors
Fields¶
| Field | Type | Nullable | Mutable | Description |
|---|---|---|---|---|
| id | UUID | No | No | Surrogate primary key |
| tenant_id | UUID | No | No | Owning tenant |
| code | str | No | No | Unique item code within tenant namespace (max 50 chars) |
| name | str | No | Yes | Display name (max 200 chars) |
| item_type | ItemType | No | No | Classification; controls which modules reference the item |
| storage_unit | StorageUnit | No | No | Base unit of measure for all quantities |
| status | ItemStatus | No | Yes | ACTIVE or INACTIVE |
| is_system | bool | No | No | True for GrinSystem-provided items |
| description | str | Yes | Yes | Free-text description (max 500 chars) |
| tags | list[str] | No | Yes | Search/filter labels; empty list if none |
| group_id | UUID | Yes | Yes | FK to ItemGroup; an item belongs to 0 or 1 group |
| shelf_life_days | int | Yes | Yes | Days until LOT expiry; PRODUCT and INTERMEDIATE only |
| ean | str | Yes | Yes | GS1 barcode, unique per tenant (max 20 chars) |
| created_by | UUID | No | No | User who created the item |
| created_at | datetime | No | No | Creation timestamp |
| updated_by | UUID | Yes | Yes | Last user to edit |
| updated_at | datetime | Yes | Yes | Last edit timestamp |
Methods¶
| Method | Parameters | Returns | Events Emitted | Description |
|---|---|---|---|---|
create() |
code, name, item_type, storage_unit, tenant_id, created_by, description, tags, group_id, shelf_life_days, ean | Item |
ItemCreated |
Factory — builds a new Active item and emits event |
edit() |
name, description, tags, shelf_life_days, ean, updated_by | list[DomainEvent] |
ItemUpdated (only if any field changed) |
Updates editable fields; returns empty list on no-op |
deactivate() |
deactivated_by: UUID | list[DomainEvent] |
ItemDeactivated |
Transitions ACTIVE → INACTIVE |
reactivate() |
reactivated_by: UUID | list[DomainEvent] |
ItemReactivated |
Transitions INACTIVE → ACTIVE |
assign_to_group() |
group_id: UUID | None | — | Replaces any existing group assignment |
remove_from_group() |
— | None | — | Clears group_id to None |
ItemGroup (Aggregate Root)¶
Identity: id: UUID
Entities within: none — items list their group_id on the Item aggregate; ItemGroup has no child collection.
Invariants:
- name is unique within (tenant_id, item_type) case-insensitively
- item_type is immutable after creation
- Deletion requires zero member items (enforced by handler querying ItemRepository)
Fields¶
| Field | Type | Nullable | Mutable | Description |
|---|---|---|---|---|
| id | UUID | No | No | Surrogate primary key |
| tenant_id | UUID | No | No | Owning tenant |
| name | str | No | Yes | Group display name (max 100 chars) |
| item_type | ItemType | No | No | Only items of this type may join the group |
| created_by | UUID | No | No | User who created the group |
| created_at | datetime | No | No | Creation timestamp |
| updated_by | UUID | Yes | Yes | Last user to edit |
| updated_at | datetime | Yes | Yes | Last edit timestamp |
Methods¶
| Method | Parameters | Returns | Events Emitted | Description |
|---|---|---|---|---|
create() |
name, item_type, tenant_id, created_by | ItemGroup |
— | Factory |
edit() |
name: str, updated_by: UUID | None | — | Updates group name |
2. Enumerations¶
ItemType¶
class ItemType(str, Enum):
INGREDIENT = "ingredient"
PRODUCT = "product"
INTERMEDIATE = "intermediate"
PACKAGING = "packaging"
CONSUMABLE = "consumable"
SERVICE = "service"
RESALE = "resale"
shelf_life_days applies to: PRODUCT, INTERMEDIATE only.
Warehouse extension skipped for: SERVICE.
StorageUnit¶
ItemStatus¶
3. Value Objects¶
Tags (frozen dataclass)¶
@dataclass(frozen=True)
class Tags:
values: tuple[str, ...] # deduplicated, case-insensitive, max 20 items
| Constraint | Rule |
|---|---|
| Max items | 20 |
| Max length per tag | 50 chars |
| Deduplication | Case-insensitive; duplicate-normalised values are dropped silently |
Factory: Tags.from_list(raw: list[str]) -> Tags — normalises and deduplicates input.
EAN (frozen dataclass)¶
Validation: non-empty, max 20 chars.
4. State Machine¶
Item Status Machine¶
stateDiagram-v2
[*] --> ACTIVE : Item.create()
ACTIVE --> INACTIVE : deactivate() [all preconditions met]
INACTIVE --> ACTIVE : reactivate()
| Current State | Trigger | Method | Next State | Events Emitted | Terminal? |
|---|---|---|---|---|---|
| — | CreateItem command | create() |
ACTIVE | ItemCreated |
No |
| ACTIVE | DeactivateItem command | deactivate() |
INACTIVE | ItemDeactivated |
No |
| INACTIVE | ReactivateItem command | reactivate() |
ACTIVE | ItemReactivated |
No |
Invalid Transitions:
| From | Attempted Trigger | Exception Raised |
|---|---|---|
| INACTIVE | deactivate() |
InvalidItemStateTransition("Item is already inactive") |
| ACTIVE | reactivate() |
InvalidItemStateTransition("Item is already active") |
ItemGroup Lifecycle¶
No state machine. An ItemGroup exists or is deleted. Deletion is guarded by a handler-level precondition (member_count == 0), not a domain state.
5. Domain Events¶
All domain events are frozen dataclasses. They are returned by aggregate methods and written to the transactional outbox by the event publisher, then dispatched in-process to @on_event handlers by the OutboxPoller.
ItemCreated¶
@dataclass(frozen=True)
class ItemCreated:
event_id: UUID
event_type: str = "ItemCreated"
aggregate_id: UUID # item_id
aggregate_type: str = "Item"
tenant_id: UUID
timestamp: datetime
version: int = 1
# payload
code: str
name: str
item_type: ItemType
storage_unit: StorageUnit
shelf_life_days: int | None
ean: str | None
is_system: bool
tags: tuple[str, ...]
group_id: UUID | None
created_by: UUID
Emitted by: Item.create()
Consumed by: Manufacturing, Warehouse, CRM
ItemUpdated¶
@dataclass(frozen=True)
class ItemUpdated:
event_id: UUID
event_type: str = "ItemUpdated"
aggregate_id: UUID # item_id
aggregate_type: str = "Item"
tenant_id: UUID
timestamp: datetime
version: int = 1
# payload — always full current values of all editable fields
name: str
description: str | None
tags: tuple[str, ...]
shelf_life_days: int | None
old_ean: str | None
new_ean: str | None
updated_by: UUID
Emitted by: Item.edit() — only when at least one field value changed.
Consumed by: Warehouse, CRM
ItemDeactivated¶
@dataclass(frozen=True)
class ItemDeactivated:
event_id: UUID
event_type: str = "ItemDeactivated"
aggregate_id: UUID # item_id
aggregate_type: str = "Item"
tenant_id: UUID
timestamp: datetime
version: int = 1
# payload
code: str
item_type: ItemType
deactivated_by: UUID
Emitted by: Item.deactivate()
Consumed by: Manufacturing, Warehouse, CRM
ItemReactivated¶
@dataclass(frozen=True)
class ItemReactivated:
event_id: UUID
event_type: str = "ItemReactivated"
aggregate_id: UUID # item_id
aggregate_type: str = "Item"
tenant_id: UUID
timestamp: datetime
version: int = 1
# payload
code: str
item_type: ItemType
reactivated_by: UUID
Emitted by: Item.reactivate()
Consumed by: CRM
6. Repository Interfaces¶
ItemRepository (ABC)¶
class ItemRepository(ABC):
@abstractmethod
async def get_by_id(self, item_id: UUID, tenant_id: UUID) -> Item:
"""Raises: ItemNotFound"""
@abstractmethod
async def get_by_code(self, code: str, tenant_id: UUID) -> Item | None:
"""Case-insensitive lookup. Returns None if not found."""
@abstractmethod
async def get_by_ean(self, ean: str, tenant_id: UUID) -> Item | None:
"""Returns None if not found."""
@abstractmethod
async def save(self, item: Item) -> None:
"""Insert or update."""
@abstractmethod
async def list_items(
self,
tenant_id: UUID,
q: str | None,
item_types: list[ItemType] | None,
active: bool | None,
group_id: UUID | None,
page: int,
page_size: int,
) -> tuple[list[Item], int]:
"""Full-text, diacritic-insensitive search across name/code/description/group_name/tags.
Returns (items_on_page, total_count)."""
@abstractmethod
async def list_by_group(
self,
tenant_id: UUID,
group_id: UUID,
page: int,
page_size: int,
) -> tuple[list[Item], int]:
"""Returns (items_on_page, total_count)."""
@abstractmethod
async def count_by_group(self, tenant_id: UUID, group_id: UUID) -> int:
"""Used by DeleteItemGroup handler to verify zero members."""
ItemGroupRepository (ABC)¶
class ItemGroupRepository(ABC):
@abstractmethod
async def get_by_id(self, group_id: UUID, tenant_id: UUID) -> ItemGroup:
"""Raises: ItemGroupNotFound"""
@abstractmethod
async def get_by_name_and_type(
self,
name: str,
item_type: ItemType,
tenant_id: UUID,
) -> ItemGroup | None:
"""Case-insensitive name lookup within (tenant, item_type). Returns None if not found."""
@abstractmethod
async def save(self, group: ItemGroup) -> None:
"""Insert or update."""
@abstractmethod
async def delete(self, group_id: UUID, tenant_id: UUID) -> None:
"""Hard delete. Handler must confirm zero members before calling."""
@abstractmethod
async def list_groups(
self,
tenant_id: UUID,
item_type: ItemType | None,
page: int,
page_size: int,
) -> tuple[list[ItemGroup], int]:
"""Returns (groups_on_page, total_count). Each result includes member count from repo query."""
7. Cross-Module Query Ports¶
These ABCs live in the domain layer and enable in-process deactivation checks. This is a documented exception to the events-only inter-module rule — read-only, synchronous, no state mutation.
class WarehouseItemQueryPort(ABC):
@abstractmethod
async def get_stock_quantity(self, item_id: UUID, tenant_id: UUID) -> Decimal:
"""Returns current total stock. Zero means safe to deactivate."""
class ManufacturingItemQueryPort(ABC):
@abstractmethod
async def count_open_production_orders(self, item_id: UUID, tenant_id: UUID) -> int:
"""Returns number of open ZPs referencing this item."""
@abstractmethod
async def count_published_bom_appearances(self, item_id: UUID, tenant_id: UUID) -> int:
"""Returns number of published recipe BOMs containing this item."""
class CRMItemQueryPort(ABC):
@abstractmethod
async def count_open_sales_orders(self, item_id: UUID, tenant_id: UUID) -> int:
"""Returns number of open ZSs referencing this item."""
8. Domain Exceptions¶
| Exception | Raised When | HTTP Status |
|---|---|---|
ItemNotFound |
item_id or code does not exist for tenant | 404 |
ItemGroupNotFound |
group_id does not exist for tenant | 404 |
ItemCodeAlreadyInUse |
code already taken within tenant namespace | 409 |
EANAlreadyInUse |
ean already assigned to another item in the tenant | 409 |
SystemItemIsReadOnly |
tenant actor tries to edit/deactivate a system item | 403 |
SystemGroupIsReadOnly |
tenant actor tries to edit/delete a system group | 403 |
ImmutableFieldCannotBeChanged |
attempt to change code, item_type, or storage_unit on edit | 400 |
ShelfLifeDaysNotApplicable |
shelf_life_days set on non-PRODUCT/INTERMEDIATE item type | 400 |
GroupItemTypeMismatch |
group.item_type ≠ item.item_type on assign | 409 |
ItemHasStockRemaining |
stock > 0 during DeactivateItem | 409 |
ItemHasOpenProductionOrders |
open ZPs exist during DeactivateItem | 409 |
ItemIsInPublishedBOM |
item appears in published recipe during DeactivateItem | 409 |
ItemHasOpenSalesOrders |
open ZSs exist during DeactivateItem | 409 |
InvalidItemStateTransition |
deactivate on inactive item or reactivate on active item | 400 |
ItemGroupNameAlreadyInUse |
group name taken within (tenant, item_type) | 409 |
ItemGroupHasMembers |
delete attempted with member_count > 0 | 409 |
ImportValidationFailed |
one or more rows fail validation during ImportItems | 400 |
ImportFileTooLarge |
file exceeds 5 MB | 400 |
ImportTooManyRows |
file has more than 500 data rows | 400 |
ItemGroupNotAssigned |
RemoveItemFromGroup called when item has no group | 400 |