Context module for managing catalogues, manufacturers, suppliers, categories, and items.
Soft-Delete System
Catalogues, categories, and items support soft-delete via a status field set to "deleted".
Manufacturers and suppliers use hard-delete only (they are reference data).
Cascade behaviour
Downward cascade on trash/permanently_delete:
- Trashing a catalogue → trashes all its categories and their items
- Trashing a category → trashes all its items
- Permanently deleting follows the same cascade but removes from DB
Upward cascade on restore:
- Restoring an item → restores its parent category if deleted
- Restoring a category → restores its parent catalogue if deleted, plus all items
All cascading operations are wrapped in database transactions.
Usage from IEx
alias PhoenixKitCatalogue.Catalogue
# Create a full hierarchy
{:ok, cat} = Catalogue.create_catalogue(%{name: "Kitchen"})
{:ok, category} = Catalogue.create_category(%{name: "Frames", catalogue_uuid: cat.uuid})
{:ok, item} = Catalogue.create_item(%{name: "Oak Panel", category_uuid: category.uuid, base_price: 25.50})
# Soft-delete and restore
{:ok, _} = Catalogue.trash_catalogue(cat) # cascades to category + item
{:ok, _} = Catalogue.restore_catalogue(cat) # cascades back
# Move operations
{:ok, _} = Catalogue.move_category_to_catalogue(category, other_catalogue_uuid)
{:ok, _} = Catalogue.move_item_to_category(item, other_category_uuid)
Summary
Functions
Returns a changeset for tracking catalogue changes.
Returns a changeset for tracking category changes.
Returns a changeset for tracking item changes.
Creates a catalogue.
Creates a category within a catalogue.
Creates an item.
Hard-deletes a catalogue. Prefer trash_catalogue/1 for soft-delete.
Hard-deletes a category. Prefer trash_category/1 for soft-delete.
Hard-deletes an item. Prefer trash_item/1 for soft-delete.
Returns the count of soft-deleted catalogues.
Fetches a catalogue by UUID without preloading categories or items.
Raises Ecto.NoResultsError if not found. Prefer this over
get_catalogue!/2 in read paths that don't need the nested preloads
(e.g. the infinite-scroll detail view, which pages categories and
items separately).
Fetches a catalogue by UUID without preloads. Returns nil if not found.
Fetches a catalogue by UUID with preloaded categories and items.
Raises Ecto.NoResultsError if not found.
Fetches a category by UUID. Returns nil if not found.
Fetches a category by UUID. Raises Ecto.NoResultsError if not found.
Fetches an item by UUID without preloads. Returns nil if not found.
Fetches an item by UUID with preloaded category and manufacturer.
Raises Ecto.NoResultsError if not found.
Counts items in a single category (ignoring its catalogue scope).
Returns a map of %{category_uuid => item_count} for every category
in a catalogue in a single grouped query. Used by the infinite-scroll
detail view so each category card can show its total count without a
separate per-card round trip.
Returns the full pricing breakdown for an item within its catalogue.
Lists all non-deleted categories across all non-deleted catalogues.
Lists catalogues, ordered by name. Excludes deleted by default.
Lists catalogues whose name starts with prefix, case-insensitive.
Lists non-deleted categories for a catalogue, ordered by position then name.
Lists categories for a catalogue without preloading items, ordered by position then name. Used by the infinite-scroll detail view to walk categories in display order without fetching potentially thousands of items up front.
Lists all non-deleted items across all catalogues, ordered by name.
Lists non-deleted items for a catalogue, ordered by category position then item name. Includes uncategorized items (those with no category) at the end.
Lists non-deleted items for a category, ordered by name.
Lists a page of items for a single category, ordered by name.
Lists uncategorized items (no category assigned) for a specific catalogue.
Lists a page of uncategorized items for a catalogue, ordered by name.
Moves a category (and all its items) to a different catalogue.
Moves an item to a different catalogue, clearing its category.
Moves an item to a different category.
Returns the next available position for a new category in a catalogue.
Permanently deletes a catalogue and all its contents from the database.
Permanently deletes a category and all its items from the database.
Permanently deletes an item from the database. This cannot be undone.
Restores a soft-deleted catalogue by setting its status to "active".
Restores a soft-deleted category by setting its status to "active".
Restores a soft-deleted item by setting its status to "active".
Atomically swaps the positions of two categories within a transaction.
Soft-deletes a catalogue by setting its status to "deleted".
Soft-deletes a category by setting its status to "deleted".
Soft-deletes an item by setting its status to "deleted".
Bulk soft-deletes all non-deleted items in a category.
Counts non-deleted uncategorized items for a catalogue (items with
category_uuid IS NULL). Used to decide whether the infinite-scroll
detail view needs to show an "Uncategorized" card at all.
Updates a catalogue with the given attributes.
Updates a category with the given attributes.
Updates an item with the given attributes.
Functions
See PhoenixKitCatalogue.Catalogue.Rules.catalogue_reference_count/1.
See PhoenixKitCatalogue.Catalogue.Rules.catalogue_rule_map/1.
See PhoenixKitCatalogue.Catalogue.Counts.category_count_for_catalogue/1.
See PhoenixKitCatalogue.Catalogue.Counts.category_counts_by_catalogue/0.
Returns a changeset for tracking catalogue changes.
See PhoenixKitCatalogue.Catalogue.Rules.change_catalogue_rule/2.
Returns a changeset for tracking category changes.
Returns a changeset for tracking item changes.
See PhoenixKitCatalogue.Catalogue.Manufacturers.change_manufacturer/2.
See PhoenixKitCatalogue.Catalogue.Suppliers.change_supplier/2.
See PhoenixKitCatalogue.Catalogue.Search.count_search_items/2.
See PhoenixKitCatalogue.Catalogue.Search.count_search_items_in_catalogue/2.
See PhoenixKitCatalogue.Catalogue.Search.count_search_items_in_category/2.
Creates a catalogue.
Required attributes
:name— catalogue name (1-255 chars)
Optional attributes
:description— text description:status—"active"(default),"archived", or"deleted":data— flexible JSON map
Examples
Catalogue.create_catalogue(%{name: "Kitchen Furniture"})
See PhoenixKitCatalogue.Catalogue.Rules.create_catalogue_rule/2.
Creates a category within a catalogue.
Required attributes
:name— category name (1-255 chars):catalogue_uuid— the parent catalogue
Optional attributes
:description,:position(default 0),:status("active"or"deleted"):data— flexible JSON map
Examples
Catalogue.create_category(%{name: "Frames", catalogue_uuid: catalogue.uuid})
Creates an item.
Required attributes
:name— item name (1-255 chars):catalogue_uuid— the parent catalogue (required). Auto-derived from:category_uuidwhen omitted and a category is provided.
Optional attributes
:description— text description:sku— stock keeping unit (unique, max 100 chars):base_price— decimal, must be >= 0 (cost/purchase price before markup):unit—"piece"(default),"m2", or"running_meter":status—"active"(default),"inactive","discontinued", or"deleted":category_uuid— the parent category (optional — leave nil for uncategorized items):manufacturer_uuid— the manufacturer (optional):data— flexible JSON map
Examples
Catalogue.create_item(%{name: "Oak Panel 18mm", catalogue_uuid: cat.uuid, base_price: 25.50})
Catalogue.create_item(%{name: "Hinge", category_uuid: category.uuid, manufacturer_uuid: m.uuid})
See PhoenixKitCatalogue.Catalogue.Manufacturers.create_manufacturer/2.
See PhoenixKitCatalogue.Catalogue.Suppliers.create_supplier/2.
Hard-deletes a catalogue. Prefer trash_catalogue/1 for soft-delete.
See PhoenixKitCatalogue.Catalogue.Rules.delete_catalogue_rule/2.
Hard-deletes a category. Prefer trash_category/1 for soft-delete.
Hard-deletes an item. Prefer trash_item/1 for soft-delete.
See PhoenixKitCatalogue.Catalogue.Manufacturers.delete_manufacturer/2.
See PhoenixKitCatalogue.Catalogue.Suppliers.delete_supplier/2.
Returns the count of soft-deleted catalogues.
See PhoenixKitCatalogue.Catalogue.Counts.deleted_category_count_for_catalogue/1.
See PhoenixKitCatalogue.Catalogue.Counts.deleted_count_for_catalogue/1.
See PhoenixKitCatalogue.Catalogue.Counts.deleted_item_count_for_catalogue/1.
Fetches a catalogue by UUID without preloading categories or items.
Raises Ecto.NoResultsError if not found. Prefer this over
get_catalogue!/2 in read paths that don't need the nested preloads
(e.g. the infinite-scroll detail view, which pages categories and
items separately).
Fetches a catalogue by UUID without preloads. Returns nil if not found.
Fetches a catalogue by UUID with preloaded categories and items.
Raises Ecto.NoResultsError if not found.
Options
:mode—:active(default) or:deleted:active— preloads non-deleted categories with non-deleted items:deleted— preloads all categories with only deleted items (so you can see which categories contain trashed items)
Examples
Catalogue.get_catalogue!(uuid) # active view
Catalogue.get_catalogue!(uuid, mode: :deleted) # deleted view
See PhoenixKitCatalogue.Catalogue.Rules.get_catalogue_rule/2.
Fetches a category by UUID. Returns nil if not found.
Fetches a category by UUID. Raises Ecto.NoResultsError if not found.
Fetches an item by UUID without preloads. Returns nil if not found.
Fetches an item by UUID with preloaded category and manufacturer.
Raises Ecto.NoResultsError if not found.
See PhoenixKitCatalogue.Catalogue.Manufacturers.get_manufacturer/1.
See PhoenixKitCatalogue.Catalogue.Manufacturers.get_manufacturer!/1.
See PhoenixKitCatalogue.Catalogue.Suppliers.get_supplier!/1.
See PhoenixKitCatalogue.Catalogue.Translations.get_translation/2.
See PhoenixKitCatalogue.Catalogue.Counts.item_count_for_catalogue/1.
Counts items in a single category (ignoring its catalogue scope).
Used by the infinite-scroll detail view to show the total under each
category header (the number in "Category Name (N items)") without
loading the items themselves.
Options
:mode—:active(default) or:deleted
See PhoenixKitCatalogue.Catalogue.Counts.item_counts_by_catalogue/0.
Returns a map of %{category_uuid => item_count} for every category
in a catalogue in a single grouped query. Used by the infinite-scroll
detail view so each category card can show its total count without a
separate per-card round trip.
Items without a category (uncategorized) are excluded here — use
uncategorized_count_for_catalogue/2 for those.
Options
:mode—:active(default) or:deleted
@spec item_pricing(PhoenixKitCatalogue.Schemas.Item.t()) :: %{ base_price: Decimal.t() | nil, catalogue_markup: Decimal.t() | nil, item_markup: Decimal.t() | nil, markup_percentage: Decimal.t() | nil, sale_price: Decimal.t() | nil, catalogue_discount: Decimal.t() | nil, item_discount: Decimal.t() | nil, discount_percentage: Decimal.t() | nil, discount_amount: Decimal.t() | nil, final_price: Decimal.t() | nil }
Returns the full pricing breakdown for an item within its catalogue.
Resolves both the catalogue's markup and discount (loading the
catalogue association once if needed), then computes the sale price
(after markup) and final price (after discount). The chain is
base → markup → discount:
sale_price = base_price * (1 + effective_markup / 100)
final_price = sale_price * (1 - effective_discount / 100)Never raises — if the catalogue can't be loaded (e.g. DB hiccup), falls back to 0% markup and 0% discount and logs a warning so the caller still gets a renderable result instead of crashing a template.
Returns a map with every field a pricing UI needs in one hop:
:base_price— the item's stored base price (ornilif unset):catalogue_markup— the catalogue'smarkup_percentage(the inherited default when the item has no override):item_markup— the item's markup override, ornilwhen inheriting from the catalogue:markup_percentage— the markup actually applied (item override if set, otherwise catalogue's):sale_price— the price after markup, before any discount (ornilif no base price):catalogue_discount— the catalogue'sdiscount_percentage:item_discount— the item's discount override, ornilwhen inheriting from the catalogue:discount_percentage— the discount actually applied (item override if set, otherwise catalogue's):discount_amount— the Decimal amount subtracted by the discount (sale_price - final_price), ornilif no discount applies or no base price:final_price— the price after both markup and discount (ornilif no base price)
Examples
# Item inherits both markup (15%) and discount (10%)
Catalogue.item_pricing(item)
#=> %{
#=> base_price: Decimal.new("100.00"),
#=> catalogue_markup: Decimal.new("15.0"),
#=> item_markup: nil,
#=> markup_percentage: Decimal.new("15.0"),
#=> sale_price: Decimal.new("115.00"),
#=> catalogue_discount: Decimal.new("10.0"),
#=> item_discount: nil,
#=> discount_percentage: Decimal.new("10.0"),
#=> discount_amount: Decimal.new("11.50"),
#=> final_price: Decimal.new("103.50")
#=> }
# Item overrides discount to 0 — sale price is charged at full
Catalogue.item_pricing(item_with_zero_discount)
#=> %{..., final_price: Decimal.new("115.00"), discount_amount: Decimal.new("0.00"), ...}
See PhoenixKitCatalogue.Catalogue.Links.link_manufacturer_supplier/2.
See PhoenixKitCatalogue.Catalogue.Links.linked_manufacturer_uuids/1.
See PhoenixKitCatalogue.Catalogue.Links.linked_supplier_uuids/1.
Lists all non-deleted categories across all non-deleted catalogues.
Category names are prefixed with their catalogue name (e.g. "Kitchen / Frames").
Useful for item move dropdowns.
See PhoenixKitCatalogue.Catalogue.Rules.list_catalogue_rules/1.
Lists catalogues, ordered by name. Excludes deleted by default.
Options
:status— when provided, returns only catalogues with this exact status (e.g."active","archived","deleted"). When nil (default), returns all non-deleted catalogues.:kind— when provided, filters to a specific kind (:standard,:smart, or their string equivalents). When nil (default), returns all kinds.
Examples
Catalogue.list_catalogues() # active + archived
Catalogue.list_catalogues(status: "deleted") # only deleted
Catalogue.list_catalogues(status: "active") # only active
Catalogue.list_catalogues(kind: :smart) # only smart catalogues
Catalogue.list_catalogues(kind: :standard) # only standard catalogues
Lists catalogues whose name starts with prefix, case-insensitive.
Anchored at the start of the name — this is a prefix match
(name ILIKE 'prefix%'), not a contains match. LIKE metacharacters
(%, _) in the prefix are escaped.
Excludes deleted catalogues by default. Useful for narrowing a search
scope: pair with search_items/2's :catalogue_uuids to search only
the matched catalogues.
Options
:status— when provided, returns only catalogues with this exact status. Defaults to non-deleted (active + archived).:limit— max results (no limit by default).
Examples
Catalogue.list_catalogues_by_name_prefix("Kit")
#=> [%Catalogue{name: "Kitchen Furniture"}, %Catalogue{name: "Kits"}]
Catalogue.list_catalogues_by_name_prefix("Kit", limit: 5)
Catalogue.list_catalogues_by_name_prefix("", limit: 10) # returns first 10
# Compose with search
uuids =
"Kit"
|> Catalogue.list_catalogues_by_name_prefix()
|> Enum.map(& &1.uuid)
Catalogue.search_items("oak", catalogue_uuids: uuids)
Lists non-deleted categories for a catalogue, ordered by position then name.
Preloads items (non-deleted only).
Lists categories for a catalogue without preloading items, ordered by position then name. Used by the infinite-scroll detail view to walk categories in display order without fetching potentially thousands of items up front.
Options
:mode—:active(default, excludes deleted categories) or:deleted(all categories — deleted categories can still contain trashed items we want to show).
Lists all non-deleted items across all catalogues, ordered by name.
Preloads category (with catalogue) and manufacturer.
Options
:status— filter by status (e.g."active","inactive"). When nil (default), returns all non-deleted items.:limit— max results to return (default: no limit)
Examples
Catalogue.list_items() # all non-deleted
Catalogue.list_items(status: "active") # only active
Catalogue.list_items(limit: 100) # first 100
Lists non-deleted items for a catalogue, ordered by category position then item name. Includes uncategorized items (those with no category) at the end.
Preloads catalogue, category (with catalogue) and manufacturer.
Lists non-deleted items for a category, ordered by name.
Preloads category (with catalogue) and manufacturer.
Lists a page of items for a single category, ordered by name.
Used by the infinite-scroll detail view; returns at most :limit
items starting at :offset. Preloads :catalogue and :manufacturer
so the table cell renderers can access them without extra queries.
Options
:mode—:active(default, excludes deleted items) or:deleted(only deleted items):offset— default0:limit— default50
See PhoenixKitCatalogue.Catalogue.Rules.list_items_referencing_catalogue/1.
See PhoenixKitCatalogue.Catalogue.Manufacturers.list_manufacturers/1.
See PhoenixKitCatalogue.Catalogue.Links.list_manufacturers_for_supplier/1.
See PhoenixKitCatalogue.Catalogue.Suppliers.list_suppliers/1.
See PhoenixKitCatalogue.Catalogue.Links.list_suppliers_for_manufacturer/1.
Lists uncategorized items (no category assigned) for a specific catalogue.
Options
:mode—:active(default) excludes deleted items;:deletedreturns only deleted items.
Examples
Catalogue.list_uncategorized_items(catalogue_uuid)
Catalogue.list_uncategorized_items(catalogue_uuid, mode: :deleted)
Lists a page of uncategorized items for a catalogue, ordered by name.
Same shape as list_items_for_category_paged/2, but for items where
category_uuid IS NULL AND catalogue_uuid = ?. Used as the final
section of the infinite-scroll detail view.
Options
:mode—:active(default) or:deleted:offset— default0:limit— default50
Moves a category (and all its items) to a different catalogue.
Automatically assigns the next available position in the target catalogue.
Examples
{:ok, moved} = Catalogue.move_category_to_catalogue(category, target_catalogue_uuid)
@spec move_item_to_catalogue( PhoenixKitCatalogue.Schemas.Item.t(), Ecto.UUID.t(), keyword() ) :: {:ok, PhoenixKitCatalogue.Schemas.Item.t()} | {:error, :catalogue_not_found | :same_catalogue | Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Item.t())}
Moves an item to a different catalogue, clearing its category.
Primarily used for smart items, where categories don't apply —
the "where does this item live?" question reduces to "which catalogue?".
Sets both catalogue_uuid and category_uuid in one update so the
item becomes uncategorized within its new catalogue.
Returns {:error, :catalogue_not_found} if the target catalogue UUID
doesn't resolve, {:error, :same_catalogue} if it's already there, or
{:error, changeset} on validation failure. Logs an item.moved
activity with from/to catalogue metadata.
Examples
{:ok, item} = Catalogue.move_item_to_catalogue(item, other_smart.uuid)
Moves an item to a different category.
If the target category lives in a different catalogue, the item's
catalogue_uuid is updated to match. Passing nil for category_uuid
detaches the item from any category while keeping it in its current
catalogue.
Examples
{:ok, item} = Catalogue.move_item_to_category(item, new_category_uuid)
{:ok, item} = Catalogue.move_item_to_category(item, nil) # make uncategorized
Returns the next available position for a new category in a catalogue.
Returns 0 if no categories exist, otherwise max_position + 1.
@spec permanently_delete_catalogue( PhoenixKitCatalogue.Schemas.Catalogue.t(), keyword() ) :: {:ok, PhoenixKitCatalogue.Schemas.Catalogue.t()} | {:error, {:referenced_by_smart_items, non_neg_integer()}} | {:error, term()}
Permanently deletes a catalogue and all its contents from the database.
Cascades downward in a transaction:
- Hard-deletes all items in the catalogue's categories
- Hard-deletes all categories
- Hard-deletes the catalogue
Refuses with {:error, {:referenced_by_smart_items, count}} when one or
more smart-catalogue items still have rules pointing at this catalogue.
V102's ON DELETE CASCADE would silently wipe those rule rows;
callers should resolve the references explicitly (or remove the rules)
before retrying. Use :force to bypass this guard at your own risk.
This cannot be undone.
Options
:actor_uuid— UUID to attribute on the activity log:force— whentrue, deletes even if smart-rule references exist
Examples
{:ok, _} = Catalogue.permanently_delete_catalogue(catalogue)
{:error, {:referenced_by_smart_items, 3}} =
Catalogue.permanently_delete_catalogue(catalogue_with_refs)
Permanently deletes a category and all its items from the database.
Cascades downward in a transaction: hard-deletes all items, then the category. This cannot be undone.
Permanently deletes an item from the database. This cannot be undone.
Examples
{:ok, _} = Catalogue.permanently_delete_item(item)
See PhoenixKitCatalogue.Catalogue.Rules.put_catalogue_rules/3.
Restores a soft-deleted catalogue by setting its status to "active".
Cascades downward in a transaction:
- All deleted categories → status
"active" - All deleted items in those categories → status
"active" - The catalogue itself → status
"active"
Examples
{:ok, catalogue} = Catalogue.restore_catalogue(catalogue)
Restores a soft-deleted category by setting its status to "active".
Cascades both directions in a transaction:
- Upward: if the parent catalogue is deleted, restores it too
- Downward: restores all deleted items in this category
Examples
{:ok, _} = Catalogue.restore_category(category)
Restores a soft-deleted item by setting its status to "active".
Cascades upward in a transaction: if the parent category is deleted, restores it too (so the item is visible in the active view).
Examples
{:ok, item} = Catalogue.restore_item(item)
See PhoenixKitCatalogue.Catalogue.Search.search_items_in_catalogue/3.
See PhoenixKitCatalogue.Catalogue.Search.search_items_in_category/3.
See PhoenixKitCatalogue.Catalogue.Translations.set_translation/5.
@spec swap_category_positions( PhoenixKitCatalogue.Schemas.Category.t(), PhoenixKitCatalogue.Schemas.Category.t(), keyword() ) :: {:ok, term()} | {:error, term()}
Atomically swaps the positions of two categories within a transaction.
Examples
{:ok, _} = Catalogue.swap_category_positions(cat_a, cat_b)
See PhoenixKitCatalogue.Catalogue.Links.sync_manufacturer_suppliers/3.
See PhoenixKitCatalogue.Catalogue.Links.sync_supplier_manufacturers/3.
Soft-deletes a catalogue by setting its status to "deleted".
Cascades downward in a transaction:
- All non-deleted items in the catalogue's categories → status
"deleted" - All non-deleted categories → status
"deleted" - The catalogue itself → status
"deleted"
Examples
{:ok, catalogue} = Catalogue.trash_catalogue(catalogue)
Soft-deletes a category by setting its status to "deleted".
Cascades downward in a transaction:
- All non-deleted items in this category → status
"deleted" - The category itself → status
"deleted"
Examples
{:ok, _} = Catalogue.trash_category(category)
Soft-deletes an item by setting its status to "deleted".
Examples
{:ok, item} = Catalogue.trash_item(item)
Bulk soft-deletes all non-deleted items in a category.
Returns {count, nil} where count is the number of items affected.
Examples
{3, nil} = Catalogue.trash_items_in_category(category_uuid)
Counts non-deleted uncategorized items for a catalogue (items with
category_uuid IS NULL). Used to decide whether the infinite-scroll
detail view needs to show an "Uncategorized" card at all.
See PhoenixKitCatalogue.Catalogue.Links.unlink_manufacturer_supplier/2.
Updates a catalogue with the given attributes.
See PhoenixKitCatalogue.Catalogue.Rules.update_catalogue_rule/3.
Updates a category with the given attributes.
Updates an item with the given attributes.
See PhoenixKitCatalogue.Catalogue.Manufacturers.update_manufacturer/3.
See PhoenixKitCatalogue.Catalogue.Suppliers.update_supplier/3.