Data Model: Item Catalog¶
Module: item_catalog Last Updated: 2026-05-10 Generated From: analysis.md
Tables¶
All tables are prefixed with ic_ to namespace them within the modular monolith. All include tenant_id for Row Level Security (RLS).
ic_item_group¶
Stores organizational groups for items. Created before items so items can reference it.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
UUID | No | gen_random_uuid() |
Primary key |
tenant_id |
UUID | No | — | Owning tenant; RLS anchor |
name |
VARCHAR(100) | No | — | Display name; unique within (tenant_id, item_type) case-insensitively |
item_type |
VARCHAR(20) | No | — | Immutable; matches ItemType enum |
created_by |
UUID | No | — | User who created the group |
created_at |
TIMESTAMPTZ | No | NOW() |
— |
updated_by |
UUID | Yes | NULL |
— |
updated_at |
TIMESTAMPTZ | Yes | NULL |
— |
Constraints:
CONSTRAINT uq_ic_item_group_name_type UNIQUE (tenant_id, item_type, lower(name))
CONSTRAINT chk_ic_item_group_item_type CHECK (
item_type IN ('ingredient','product','intermediate','packaging','consumable','service','resale')
)
ic_item¶
The canonical item registry — the core table of this module.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
UUID | No | gen_random_uuid() |
Primary key |
tenant_id |
UUID | No | — | Owning tenant; RLS anchor |
code |
VARCHAR(50) | No | — | Unique item code within tenant namespace; case-insensitive |
name |
VARCHAR(200) | No | — | Display name |
item_type |
VARCHAR(20) | No | — | Immutable; matches ItemType enum |
storage_unit |
VARCHAR(10) | No | — | Immutable; matches StorageUnit enum |
status |
VARCHAR(10) | No | 'active' |
active or inactive |
is_system |
BOOLEAN | No | false |
GrinSystem-provided items |
description |
VARCHAR(500) | Yes | NULL |
Free-text description |
group_id |
UUID | Yes | NULL |
FK → ic_item_group.id; nullable (0..1 group) |
shelf_life_days |
INTEGER | Yes | NULL |
Product and intermediate only; positive integer |
ean |
VARCHAR(20) | Yes | NULL |
GS1 barcode; unique per tenant when set |
created_by |
UUID | No | — | — |
created_at |
TIMESTAMPTZ | No | NOW() |
— |
updated_by |
UUID | Yes | NULL |
— |
updated_at |
TIMESTAMPTZ | Yes | NULL |
— |
Constraints:
CONSTRAINT uq_ic_item_code UNIQUE (tenant_id, lower(code))
CONSTRAINT uq_ic_item_ean UNIQUE (tenant_id, ean)
-- Partial: applied only where ean IS NOT NULL
CONSTRAINT chk_ic_item_status CHECK (status IN ('active', 'inactive'))
CONSTRAINT chk_ic_item_item_type CHECK (
item_type IN ('ingredient','product','intermediate','packaging','consumable','service','resale')
)
CONSTRAINT chk_ic_item_storage_unit CHECK (
storage_unit IN ('kg','g','l','ml','piece')
)
CONSTRAINT chk_ic_item_shelf_life_positive CHECK (
shelf_life_days IS NULL OR shelf_life_days > 0
)
CONSTRAINT fk_ic_item_group FOREIGN KEY (group_id)
REFERENCES ic_item_group(id)
ON DELETE RESTRICT -- prevents orphaned group references; group must be empty to delete
ic_item_tag¶
Tags stored in a child table to allow indexed filtering and avoid array column complexity.
Alternative: tags can be stored as a
text[]column with GIN index onic_item. The child-table approach is preferred for query flexibility (tag-level filtering without array operators). Choose one at implementation time.
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
id |
UUID | No | gen_random_uuid() |
Primary key |
item_id |
UUID | No | — | FK → ic_item.id ON DELETE CASCADE |
tenant_id |
UUID | No | — | Denormalized for RLS |
tag |
VARCHAR(50) | No | — | Lowercased; deduplicated at write time |
Constraints:
CONSTRAINT uq_ic_item_tag UNIQUE (item_id, lower(tag))
CONSTRAINT fk_ic_item_tag_item FOREIGN KEY (item_id)
REFERENCES ic_item(id) ON DELETE CASCADE
Full-Text Search Column (on ic_item)¶
To support diacritic-insensitive full-text search across name, code, description, group name, and tags, a generated tsvector column is maintained:
ALTER TABLE ic_item ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('simple',
unaccent(coalesce(name, '')) || ' ' ||
unaccent(coalesce(code, '')) || ' ' ||
unaccent(coalesce(description, ''))
)
) STORED;
Tag content and group name are included in search either by joining
ic_item_tagandic_item_groupat query time (viaILIKE + unaccent), or by denormalizing them into thesearch_vectorvia a trigger. Decision deferred to implementation. TheunaccentPostgreSQL extension must be enabled.
Indexes¶
| Index Name | Table | Columns | Type | Purpose |
|---|---|---|---|---|
ix_ic_item_tenant_status |
ic_item |
(tenant_id, status) |
BTREE | Status filter in ListItems |
ix_ic_item_tenant_type |
ic_item |
(tenant_id, item_type) |
BTREE | item_type multi-select filter |
ix_ic_item_group_id |
ic_item |
group_id |
BTREE | FK lookup and group filter |
ix_ic_item_search_vector |
ic_item |
search_vector |
GIN | Full-text search |
ix_ic_item_tenant_ean |
ic_item |
(tenant_id, ean) |
BTREE | EAN uniqueness check and Warehouse scan |
ix_ic_item_tag_item_id |
ic_item_tag |
item_id |
BTREE | Tag lookup by item |
ix_ic_item_group_tenant_type |
ic_item_group |
(tenant_id, item_type) |
BTREE | Group list filtered by type |
Row Level Security¶
All tables use tenant_id for RLS. Policy pattern (applied identically to all three tables):
ALTER TABLE ic_item ENABLE ROW LEVEL SECURITY;
CREATE POLICY ic_item_tenant_isolation ON ic_item
USING (tenant_id = current_setting('app.current_tenant')::uuid);
ALTER TABLE ic_item_group ENABLE ROW LEVEL SECURITY;
CREATE POLICY ic_item_group_tenant_isolation ON ic_item_group
USING (tenant_id = current_setting('app.current_tenant')::uuid);
ALTER TABLE ic_item_tag ENABLE ROW LEVEL SECURITY;
CREATE POLICY ic_item_tag_tenant_isolation ON ic_item_tag
USING (tenant_id = current_setting('app.current_tenant')::uuid);
System items and system groups have tenant_id = NULL or a reserved system tenant UUID — see provisioning design (outside scope of this doc).
Transaction Boundaries¶
| Use Case | Tables Written | Transaction Scope | Notes |
|---|---|---|---|
| UC-001: CreateItem | ic_item, ic_item_tag |
Single transaction | Item + tags written atomically |
| UC-002: EditItem | ic_item, ic_item_tag |
Single transaction | Tags: delete-all + re-insert within same transaction |
| UC-003: DeactivateItem | ic_item |
Single transaction | Status change only |
| UC-004: ReactivateItem | ic_item |
Single transaction | Status change only |
| UC-005: AssignItemToGroup | ic_item |
Single transaction | group_id update only |
| UC-006: RemoveItemFromGroup | ic_item |
Single transaction | group_id → NULL |
| UC-007: ImportItems | ic_item, ic_item_tag |
Single transaction (all rows) | All-or-nothing; all rows in one DB transaction |
| UC-008: CreateItemGroup | ic_item_group |
Single transaction | — |
| UC-009: EditItemGroup | ic_item_group |
Single transaction | — |
| UC-010: DeleteItemGroup | ic_item_group |
Single transaction | FK ON DELETE RESTRICT prevents accidental cascade |
FK Relationships Diagram¶
ic_item_group
id ─────────────────────────────┐
tenant_id │ (group_id FK)
name │
item_type │
is_system │
▼
ic_item ────────────── ic_item_tag
id ──────────────────────── item_id (FK, ON DELETE CASCADE)
tenant_id tenant_id
code tag
name
item_type
storage_unit
status
group_id (FK → ic_item_group.id, NULLABLE)
shelf_life_days
ean
Alembic Migration Notes¶
- Enable
unaccentextension before creating thesearch_vectorgenerated column. - The partial unique index on
eanrequiresCREATE UNIQUE INDEX ... WHERE ean IS NOT NULLsyntax (not expressible as a table constraint in some versions — useop.create_indexin Alembic). - The
lower(code)andlower(name)unique constraints require expression indexes, which Alembic creates viaop.create_index('...', 'ic_item', [text('lower(code)')], unique=True).