# `PhoenixKitCatalogue.Catalogue`
[🔗](https://github.com/BeamLabEU/phoenix_kit_catalogue/blob/0.8.0/lib/phoenix_kit_catalogue/catalogue.ex#L1)

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)

## Smart catalogues

For an end-to-end walkthrough of integrating smart catalogues
(`kind: "smart"` items priced as functions of other catalogues), see
the [Smart Catalogues guide](smart_catalogues.md).

# `active_item_count_in_subtree`

# `bulk_move_items_to_category`

```elixir
@spec bulk_move_items_to_category([Ecto.UUID.t()], Ecto.UUID.t() | nil, keyword()) ::
  {:ok, non_neg_integer()}
  | {:error, :category_not_found}
  | {:error, :wrong_catalogue_scope}
  | {:error, :missing_catalogue_scope}
```

Bulk-moves items to a target category within a single catalogue.

## Required opts

  * `:catalogue_uuid` — the calling LV's catalogue scope. Every item
    in `uuids` MUST already belong to this catalogue, and `target_uuid`
    (when not `nil`) must live in this catalogue. The single-item DnD
    handler enforces the same scope; this guard makes the bulk path
    symmetric so a crafted client request can't silently flip an
    item's `catalogue_uuid` cross-catalogue.

Pass `target_uuid: nil` to uncategorize all items within their
catalogue.

Returns `{:ok, count}`, `{:error, :category_not_found}` (target),
`{:error, :wrong_catalogue_scope}` (target lives elsewhere or one or
more items don't belong to `:catalogue_uuid`), or
`{:error, :missing_catalogue_scope}` (caller forgot the required opt).

# `bulk_permanently_delete_items`

```elixir
@spec bulk_permanently_delete_items(
  [Ecto.UUID.t()],
  keyword()
) :: {non_neg_integer(), nil}
```

Bulk hard-deletes items by UUID. Use with care — no soft-delete cycle.
Logs a single `item.bulk_permanently_deleted` activity row when
count > 0.

# `bulk_restore_items`

```elixir
@spec bulk_restore_items(
  [Ecto.UUID.t()],
  keyword()
) :: {non_neg_integer(), nil}
```

Bulk restores items by UUID. Skips items whose parent catalogue is
deleted (returns only the count of items actually flipped to active).
Items with deleted parent categories are uncategorized on restore —
same rule as `restore_item/2`.

Wrapped in `repo().transaction/1` so the read-then-partition-then-write
pipeline can't be interleaved with another connection flipping a
parent's status mid-flight. Without that envelope a concurrent
category trash/restore could push the partition off-by-one and either
detach an item that should have stayed attached or vice versa.

# `bulk_trash_categories`

```elixir
@spec bulk_trash_categories(
  [Ecto.UUID.t()],
  :cascade | :uncategorize | {:move_to, Ecto.UUID.t()},
  keyword()
) ::
  {:ok, %{categories: non_neg_integer(), items_handled: non_neg_integer()}}
  | {:error, term()}
```

Bulk soft-deletes categories by UUID with a uniform item disposition
(cascade / uncategorize / move_to). Each category goes through the
same logic as `trash_category/2`. Returns `{:ok, %{categories:
count, items_handled: count}}` or surfaces the first error.

# `bulk_trash_items`

```elixir
@spec bulk_trash_items(
  [Ecto.UUID.t()],
  keyword()
) :: {non_neg_integer(), nil}
```

Bulk soft-deletes items by UUID. Empty list is a no-op. Logs a single
`item.bulk_trashed` activity row when count > 0.

# `catalogue_reference_count`

# `catalogue_rule_map`

# `catalogues_by_folder`

```elixir
@spec catalogues_by_folder(keyword()) :: %{
  required(Ecto.UUID.t() | nil) =&gt; [PhoenixKitCatalogue.Schemas.Catalogue.t()]
}
```

Groups non-deleted catalogues by their folder home for the tree view.
Returns `%{(folder_uuid | nil) => [Catalogue.t()]}`. A catalogue whose
folder is trashed or missing is promoted to the `nil` (root) bucket so it
never disappears — parity with the folder/category orphan promotion.
Within a bucket, catalogues keep `position, name` order.

## Options

  * `:status` — passed through to `list_catalogues/1` (e.g. `"deleted"` for
    the deleted view). Defaults to non-deleted (active + archived).

# `category_count_for_catalogue`

# `category_counts_by_catalogue`

# `category_summary_for_catalogue`

```elixir
@spec category_summary_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: %{
  categories: [PhoenixKitCatalogue.Schemas.Category.t()],
  item_counts: %{required(Ecto.UUID.t()) =&gt; non_neg_integer()},
  uncategorized_count: non_neg_integer()
}
```

One-shot helper for lazy-loading a catalogue's category tree. Returns
category metadata plus per-category and uncategorized item counts in
two queries instead of three.

Combines the work of:

  * `list_categories_metadata_for_catalogue/2`
  * `item_counts_by_category_for_catalogue/2`
  * `uncategorized_count_for_catalogue/2`

Categories are ordered the same way `list_categories_metadata_for_catalogue/2`
orders them. Empty categories don't appear in `:item_counts` (treat
missing keys as `0`).

## Options

  * `:mode` — `:active` (default, excludes deleted) or `:deleted`.
    Mode is applied uniformly to both the categories query and the
    item-count query.

# `category_uuids_with_children`

```elixir
@spec category_uuids_with_children(
  Ecto.UUID.t(),
  keyword()
) :: MapSet.t()
```

Returns the set of category UUIDs (within the catalogue, in the given
`:mode`) that have at least one child category — lets drill cards show
a "has subcategories" affordance without an N+1 per card.

# `change_catalogue`

```elixir
@spec change_catalogue(PhoenixKitCatalogue.Schemas.Catalogue.t(), map()) ::
  Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Catalogue.t())
```

Returns a changeset for tracking catalogue changes.

# `change_catalogue_rule`

# `change_category`

```elixir
@spec change_category(PhoenixKitCatalogue.Schemas.Category.t(), map()) ::
  Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Category.t())
```

Returns a changeset for tracking category changes.

# `change_item`

```elixir
@spec change_item(PhoenixKitCatalogue.Schemas.Item.t(), map()) ::
  Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Item.t())
```

Returns a changeset for tracking item changes.

# `change_manufacturer`

# `change_supplier`

# `count_pdfs`

# `count_search_items`

# `count_search_items_in_catalogue`

# `count_search_items_in_category`

# `create_catalogue`

```elixir
@spec create_catalogue(
  map(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Catalogue.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Catalogue.t())}
```

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"})

# `create_catalogue_rule`

# `create_category`

```elixir
@spec create_category(
  map(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Category.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Category.t())}
```

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})

# `create_folder`

```elixir
@spec create_folder(
  map(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Folder.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Folder.t())}
```

Creates a folder. `:parent_uuid` (optional) nests it; a new folder is
appended (max sibling position + 1) within its parent level.

# `create_item`

```elixir
@spec create_item(
  map(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Item.t())}
```

Creates an item.

## Required attributes

  * `:name` — item name (1-255 chars)
  * `:catalogue_uuid` — the parent catalogue (required). Auto-derived from
    `:category_uuid` when omitted and a category is provided.

## Optional attributes

  * `:description` — text description
  * `:sku` — stock keeping unit (max 100 chars; not unique — the same
    SKU may appear on multiple items)
  * `: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})

# `create_manufacturer`

# `create_pdf_from_upload`

# `create_supplier`

# `delete_catalogue`

```elixir
@spec delete_catalogue(
  PhoenixKitCatalogue.Schemas.Catalogue.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Catalogue.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Catalogue.t())}
```

Hard-deletes a catalogue. Prefer `trash_catalogue/1` for soft-delete.

# `delete_catalogue_rule`

# `delete_category`

```elixir
@spec delete_category(
  PhoenixKitCatalogue.Schemas.Category.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Category.t()} | {:error, term()}
```

Hard-deletes a category. Prefer `trash_category/1` for soft-delete.

# `delete_item`

```elixir
@spec delete_item(
  PhoenixKitCatalogue.Schemas.Item.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Item.t()} | {:error, term()}
```

Hard-deletes an item. Prefer `trash_item/1` for soft-delete.

# `delete_manufacturer`

# `delete_supplier`

# `deleted_catalogue_count`

```elixir
@spec deleted_catalogue_count() :: non_neg_integer()
```

Returns the count of soft-deleted catalogues.

# `deleted_category_count_for_catalogue`

# `deleted_count_for_catalogue`

# `deleted_item_count_for_catalogue`

# `evaluate_smart_rules`

# `fetch_catalogue!`

```elixir
@spec fetch_catalogue!(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.Catalogue.t()
```

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).

# `folder_uuids_with_children`

```elixir
@spec folder_uuids_with_children(keyword()) :: MapSet.t()
```

Returns the set of folder UUIDs (in the given `:mode`) that have at
least one child folder — lets the tree show an expand affordance
without an N+1.

# `get_catalogue`

```elixir
@spec get_catalogue(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.Catalogue.t() | nil
```

Fetches a catalogue by UUID without preloads. Returns `nil` if not found.

# `get_catalogue!`

```elixir
@spec get_catalogue!(
  Ecto.UUID.t(),
  keyword()
) :: PhoenixKitCatalogue.Schemas.Catalogue.t()
```

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

# `get_catalogue_rule`

# `get_category`

```elixir
@spec get_category(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.Category.t() | nil
```

Fetches a category by UUID. Returns `nil` if not found.

# `get_category!`

```elixir
@spec get_category!(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.Category.t()
```

Fetches a category by UUID. Raises `Ecto.NoResultsError` if not found.

# `get_folder`

```elixir
@spec get_folder(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.Folder.t() | nil
```

Fetches a folder by UUID. Returns `nil` if not found.

# `get_item`

```elixir
@spec get_item(
  Ecto.UUID.t(),
  keyword()
) :: PhoenixKitCatalogue.Schemas.Item.t() | nil
```

Fetches an item by UUID. Returns `nil` if not found.

## Options

  * `:preload` — list of associations to preload. Default `[]`.
    Common smart-pricing preload: `[catalogue_rules: :referenced_catalogue]`.

## Examples

    Catalogue.get_item(uuid)
    Catalogue.get_item(uuid, preload: [:catalogue, catalogue_rules: :referenced_catalogue])

# `get_item!`

```elixir
@spec get_item!(
  Ecto.UUID.t(),
  keyword()
) :: PhoenixKitCatalogue.Schemas.Item.t()
```

Fetches an item by UUID with preloaded `:catalogue`, `:category`, and
`:manufacturer`. Raises `Ecto.NoResultsError` if not found.

Pass `:preload` to add more associations (concatenated with the
defaults).

# `get_manufacturer`

# `get_manufacturer!`

# `get_pdf`

# `get_pdf!`

# `get_pdf_extraction`

# `get_supplier`

# `get_supplier!`

# `get_translation`

# `item_count_for_catalogue`

# `item_count_for_category`

```elixir
@spec item_count_for_category(
  Ecto.UUID.t(),
  keyword()
) :: non_neg_integer()
```

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`

# `item_counts_by_catalogue`

# `item_counts_by_category_for_catalogue`

```elixir
@spec item_counts_by_category_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: %{required(Ecto.UUID.t()) =&gt; non_neg_integer()}
```

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`

# `item_pricing`

```elixir
@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 (or `nil` if unset)
  * `:catalogue_markup` — the catalogue's `markup_percentage` (the
    inherited default when the item has no override)
  * `:item_markup` — the item's markup override, or `nil` when
    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
    (or `nil` if no base price)
  * `:catalogue_discount` — the catalogue's `discount_percentage`
  * `:item_discount` — the item's discount override, or `nil` when
    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`), or `nil` if no discount applies or
    no base price
  * `:final_price` — the price after both markup and discount (or
    `nil` if 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"), ...}

# `item_status_counts_for_category`

```elixir
@spec item_status_counts_for_category(Ecto.UUID.t()) :: %{
  required(String.t()) =&gt; non_neg_integer()
}
```

Returns `%{status => count}` for the items in a single category, across
every status (`"active"`, `"inactive"`, `"discontinued"`, `"deleted"`).
One grouped query — drives the detail page's per-status item tabs.
Missing statuses are simply absent from the map (treat as 0).

# `item_status_counts_for_uncategorized`

```elixir
@spec item_status_counts_for_uncategorized(Ecto.UUID.t()) :: %{
  required(String.t()) =&gt; non_neg_integer()
}
```

`%{status => count}` for a catalogue's uncategorized items (`category_uuid
IS NULL`), across every status. Per-status sibling of
`uncategorized_count_for_catalogue/2`.

# `link_manufacturer_supplier`

# `linked_manufacturer_uuids`

# `linked_supplier_uuids`

# `list_all_categories`

```elixir
@spec list_all_categories() :: [PhoenixKitCatalogue.Schemas.Category.t()]
```

Lists all non-deleted categories across all non-deleted catalogues,
with breadcrumb-style names prefixed by their catalogue and every
ancestor category (e.g. `"Kitchen / Cabinets / Frames"`). Useful for
item move dropdowns where the user needs to distinguish
same-named leaves under different parents.

Entries are grouped by catalogue (catalogues ordered by name) and
within each catalogue returned in depth-first display order.

One query for catalogues + one query for all their categories — the
tree walk and breadcrumb rewrite happen in memory. Safe to call on
demand from move-dropdowns.

# `list_catalogue_rules`

# `list_catalogues`

```elixir
@spec list_catalogues(keyword()) :: [PhoenixKitCatalogue.Schemas.Catalogue.t()]
```

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.
  * `:folder_uuid` — when provided, filters by folder home: a folder UUID
    returns only catalogues filed there, `:unfiled` returns root (NULL-folder)
    catalogues. When omitted, returns catalogues in any folder. Note this is a
    strict DB filter and does NOT orphan-promote catalogues whose folder is
    trashed — the tree view groups in-memory against the active folder set.

## 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
    Catalogue.list_catalogues(folder_uuid: :unfiled) # only root (unfiled)

# `list_catalogues_by_name_prefix`

```elixir
@spec list_catalogues_by_name_prefix(
  String.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Catalogue.t()]
```

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)

# `list_categories_for_catalogue`

```elixir
@spec list_categories_for_catalogue(Ecto.UUID.t()) :: [
  PhoenixKitCatalogue.Schemas.Category.t()
]
```

Lists non-deleted categories for a catalogue, ordered by position then name.

Preloads items (non-deleted only).

# `list_categories_metadata_for_catalogue`

```elixir
@spec list_categories_metadata_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Category.t()]
```

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).

# `list_category_ancestors`

```elixir
@spec list_category_ancestors(Ecto.UUID.t()) :: [
  PhoenixKitCatalogue.Schemas.Category.t()
]
```

Returns the list of ancestor categories from root down to (but not
including) `category_uuid`. Empty when the category is a root.
Useful for breadcrumbs.

# `list_category_tree`

```elixir
@spec list_category_tree(
  Ecto.UUID.t(),
  keyword()
) :: [{PhoenixKitCatalogue.Schemas.Category.t(), non_neg_integer()}]
```

Returns the categories in a catalogue paired with their tree depth,
in depth-first display order (position, then name, recursing into
children). Each entry is `{category, depth}` where depth `0` means a
root. Used to render flat parent-pickers and indented listings.

## Options

  * `:mode` — `:active` (default, excludes deleted categories) or
    `:deleted` (all statuses — the detail view in deleted mode still
    wants deleted categories that contain trashed items).
  * `:exclude_subtree_of` — skip a category and all its descendants
    (e.g. the category being edited — you can't parent it under
    itself or its descendants).

# `list_child_categories`

```elixir
@spec list_child_categories(Ecto.UUID.t(), Ecto.UUID.t() | nil, keyword()) :: [
  PhoenixKitCatalogue.Schemas.Category.t()
]
```

Lists the categories shown at one drill level — the direct children of
`parent_uuid` within the catalogue (`nil` = the root level).

In `:active` mode (default) the result reuses `list_category_tree/2`'s
orphan promotion: a category whose parent is deleted (e.g. a child
restored under a still-trashed parent — `restore_category/2` does not
cascade) surfaces at the root level so it stays reachable by
drill-down. In `:deleted` mode it returns the strict set of *deleted*
direct children (no promotion) — the deleted subtree is navigated by
drilling into deleted parents.

Ordered by `position` then `name`.

# `list_deleted_items_for_catalogue`

```elixir
@spec list_deleted_items_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]
```

Lists soft-deleted items in a catalogue as a flat list, ordered by
deletion date (most-recently-deleted first). `updated_at` is the
deletion-time proxy — flipping `status` to `"deleted"` always bumps
it. Used by the Items tab Deleted view, which surfaces a recency-
ordered audit list rather than category-grouped cards.

## Options

  * `:limit` — caps the list (default 500). Pagination isn't wired
    yet; if a catalogue routinely exceeds the limit, layer a cursor
    on top of this query.
  * `:preload` — extra associations on top of the default
    `[:catalogue, category: :catalogue, manufacturer: []]`.

## Examples

    Catalogue.list_deleted_items_for_catalogue(catalogue_uuid)

# `list_folder_tree`

```elixir
@spec list_folder_tree(keyword()) :: [
  {PhoenixKitCatalogue.Schemas.Folder.t(), non_neg_integer()}
]
```

Returns folders paired with their tree depth, in depth-first display
order (`position`, then `name`, recursing into children). Each entry is
`{folder, depth}` where depth `0` is a root. Mirrors
`list_category_tree/2` but folders are module-global.

## Options

  * `:mode` — `:active` (default, excludes deleted) or `:deleted`.
  * `:exclude_subtree_of` — skip a folder and all its descendants (the
    folder being moved — you can't parent it under itself/its subtree).

# `list_items`

```elixir
@spec list_items(keyword()) :: [PhoenixKitCatalogue.Schemas.Item.t()]
```

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

# `list_items_by_uuids`

```elixir
@spec list_items_by_uuids(
  [Ecto.UUID.t()],
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]
```

Bulk-fetches items by a list of UUIDs. Excludes soft-deleted items.
Result order matches the input UUID order; missing UUIDs are dropped
(no `nil` placeholders, no error). Duplicate input UUIDs collapse to
a single result.

Designed for snapshot rehydration — e.g. an order stored as a list of
item UUIDs that needs full item data on reload. Avoids the N+1 trap
of looping `get_item/1` per UUID.

## Options

  * `:preload` — extra associations appended to the default
    `[:catalogue, :category, :manufacturer]`. Pass
    `[catalogue_rules: :referenced_catalogue]` for smart-pricing.

## Examples

    Catalogue.list_items_by_uuids([uuid1, uuid2, uuid3])
    Catalogue.list_items_by_uuids(uuids, preload: [catalogue_rules: :referenced_catalogue])

# `list_items_for_catalogue`

```elixir
@spec list_items_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]
```

Lists non-deleted items for a catalogue, ordered by category position then
item name. Includes uncategorized items (those with no category) at the end.

Default preloads `[:catalogue, category: :catalogue, manufacturer: []]`.
Pass `:preload` in `opts` to add more — see `list_items_for_category/2`.

# `list_items_for_category`

```elixir
@spec list_items_for_category(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]
```

Lists non-deleted items for a category, ordered by position then name.

Default preloads `[:catalogue, category: :catalogue, manufacturer: []]`.
Pass `:preload` in `opts` to add more (e.g.
`preload: [catalogue_rules: :referenced_catalogue]` for smart-pricing
consumers); the lists are concatenated, not replaced.

# `list_items_for_category_paged`

```elixir
@spec list_items_for_category_paged(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]
```

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` — default `0`
  * `:limit` — default `50`
  * `:preload` — extra associations appended to the default
    `[:catalogue, :manufacturer]`.

# `list_items_referencing_catalogue`

# `list_manufacturers`

# `list_manufacturers_for_supplier`

# `list_move_target_categories`

```elixir
@spec list_move_target_categories(PhoenixKitCatalogue.Schemas.Category.t()) :: [
  {PhoenixKitCatalogue.Schemas.Category.t(), non_neg_integer()}
]
```

Returns same-catalogue active categories that can receive items from
a category about to be deleted (the category itself and its V103
descendants are excluded). Used by the admin "delete category" modal
to populate the move-target dropdown.

Each entry is `{category, depth}`, depth-first order — the same shape
`list_category_tree/2` returns so callers can render the same indent
rules.

# `list_pdfs`

# `list_suppliers`

# `list_suppliers_for_manufacturer`

# `list_uncategorized_items`

```elixir
@spec list_uncategorized_items(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]
```

Lists uncategorized items (no category assigned) for a specific catalogue.

## Options

  * `:mode` — `:active` (default) excludes deleted items;
    `:deleted` returns only deleted items.
  * `:preload` — extra associations appended to the default
    `[:catalogue, :manufacturer]` preloads. Pass
    `[catalogue_rules: :referenced_catalogue]` for smart-pricing.

## Examples

    Catalogue.list_uncategorized_items(catalogue_uuid)
    Catalogue.list_uncategorized_items(catalogue_uuid, mode: :deleted)

# `list_uncategorized_items_paged`

```elixir
@spec list_uncategorized_items_paged(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]
```

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` — default `0`
  * `:limit` — default `50`
  * `:preload` — extra associations appended to the default
    `[:catalogue, :manufacturer]`.

# `more_pdf_matches_for_item`

# `move_catalogue_to_folder`

```elixir
@spec move_catalogue_to_folder(
  PhoenixKitCatalogue.Schemas.Catalogue.t(),
  Ecto.UUID.t() | nil | :unfiled,
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Catalogue.t()}
  | {:error, :folder_not_found | :folder_trashed | term()}
```

Files a catalogue into `folder_uuid` (`nil`/`:unfiled` = root). Rejects
a trashed/missing target folder; appends to the end of the target
level. No-op (no write, no log) when already there.

# `move_category_to_catalogue`

```elixir
@spec move_category_to_catalogue(
  PhoenixKitCatalogue.Schemas.Category.t(),
  Ecto.UUID.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Category.t()} | {:error, term()}
```

Moves a category — along with its entire subtree and every item
inside — to a different catalogue.

The moved category's `parent_uuid` is cleared (it detaches from its
former parent, which stays in the source catalogue) and it takes the
next available root-level position in the target. Internal parent
links inside the moved subtree are preserved.

Automatically assigns the next available root position in the target
catalogue.

## Examples

    {:ok, moved} = Catalogue.move_category_to_catalogue(category, target_catalogue_uuid)

# `move_category_under`

```elixir
@spec move_category_under(
  PhoenixKitCatalogue.Schemas.Category.t(),
  Ecto.UUID.t() | nil,
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Category.t()}
  | {:error,
     :would_create_cycle
     | :cross_catalogue
     | :parent_not_found
     | Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Category.t())}
```

Reparents a category within the same catalogue, placing it under
`new_parent_uuid` (or promoting it to a root with `nil`).

Rejects moves that would:
  * produce a cycle (`new_parent_uuid` is the category itself or one
    of its descendants) — returns `{:error, :would_create_cycle}`
  * cross a catalogue boundary — returns `{:error, :cross_catalogue}`.
    Callers who want that should run `move_category_to_catalogue/3`
    first, then reparent.
  * target a missing parent — returns `{:error, :parent_not_found}`

The moved category takes the next-available position among its new
siblings. Its subtree comes along untouched (parent links inside the
subtree stay valid).

Passing `new_parent_uuid = nil` promotes the category to a root within
its current catalogue.

## Examples

    {:ok, moved} = Catalogue.move_category_under(child, parent.uuid)
    {:ok, moved} = Catalogue.move_category_under(child, nil)  # promote to root

# `move_folder`

```elixir
@spec move_folder(
  PhoenixKitCatalogue.Schemas.Folder.t(),
  Ecto.UUID.t() | nil,
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Folder.t()}
  | {:error, :cycle | :folder_not_found | :folder_trashed | term()}
```

Moves a folder under `new_parent_uuid` (`nil` = root). Rejects a move
into the folder's own subtree (cycle) or into a trashed/missing parent.
The folder is appended at the end of the target level. No-op when the
parent is unchanged.

# `move_item_and_reorder_destination`

```elixir
@spec move_item_and_reorder_destination(
  PhoenixKitCatalogue.Schemas.Item.t(),
  Ecto.UUID.t() | nil,
  [Ecto.UUID.t()],
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error, :category_not_found | :wrong_scope | :too_many_uuids | term()}
```

Atomic combine of `move_item_to_category/3` and `reorder_items/4` for
the cross-category drag-and-drop case.

The DnD path triggers two writes on a single drop: the moved item's
`category_uuid` flips, and the destination category's `position`
values get re-indexed to match the visual order. Calling the two
context fns separately leaves a window where the move commits but
the reorder rolls back, leaving the item in the new category with
a stale position. Wrapping both in a single `repo().transaction/1`
closes that window — either both land or both roll back.

Calls the unlogged `validate_and_apply_item_reorder_in_txn/3` so
rejection / db-error audit rows are written **outside** the outer
transaction. Otherwise a rejection inside the inner reorder would
log a row that the outer rollback then discards, reopening the
audit-trail gap.

Activity-log fan-out: `item.moved` lands inside the inner
`move_item_to_category/3` (rolled back if the reorder fails, which
is correct — the move didn't actually happen). `item.reordered`
lands here, after the outer transaction commits.

# `move_item_to_catalogue`

```elixir
@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)

# `move_item_to_category`

```elixir
@spec move_item_to_category(
  PhoenixKitCatalogue.Schemas.Item.t(),
  Ecto.UUID.t() | nil,
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error,
     :category_not_found
     | Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Item.t())}
```

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

# `next_catalogue_position`

```elixir
@spec next_catalogue_position() :: integer()
```

Returns the next available `position` for a new catalogue — one past
the current max, falling back to `1` on an empty table.

# `next_category_position`

```elixir
@spec next_category_position(Ecto.UUID.t(), Ecto.UUID.t() | nil) :: non_neg_integer()
```

Returns the next available position for a new category among its
siblings. Position is scoped to `(catalogue_uuid, parent_uuid)` — the
set of categories sharing the same parent within a catalogue — since
V103's nested-category tree makes a single catalogue-wide ordering
ambiguous.

`parent_uuid` defaults to `nil`, i.e. root-level siblings. Returns 0
if no siblings exist at that level, otherwise `max_position + 1`.

# `next_item_position`

```elixir
@spec next_item_position(Ecto.UUID.t(), Ecto.UUID.t() | nil) :: integer()
```

Returns the next available `position` for a new item within a scope.

Items are scoped to `(catalogue_uuid, category_uuid)`. Pass
`category_uuid: nil` for the uncategorized bucket of a catalogue.

# `normalize_category_uuid`

```elixir
@spec normalize_category_uuid(
  nil
  | :uncategorized
  | String.t()
  | PhoenixKitCatalogue.Schemas.Category.t()
) :: Ecto.UUID.t() | nil
```

Normalizes a node reference to an item `category_uuid`: `nil` /
`:uncategorized` / `"uncategorized"` → `nil` (the uncategorized scope);
`%Category{}` → its uuid; a uuid string passes through. Shared by the
detail LV and `reorder_items_by/5` so the uncategorized bucket always
reaches scope checks as `category_uuid: nil`.

# `permanently_delete_catalogue`

```elixir
@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:
1. Hard-deletes all items in the catalogue's categories
2. Hard-deletes all categories
3. 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` — when `true`, 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_delete_category`

```elixir
@spec permanently_delete_category(
  PhoenixKitCatalogue.Schemas.Category.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Category.t()} | {:error, term()}
```

Permanently deletes a category and its entire subtree (all descendant
categories + every item in any of them) from the database.

**Cascades downward** in a transaction, following the nested-category
tree introduced in V103. Items are hard-deleted first, then the
subtree categories from leaves up (ordered so child FKs resolve
before their parent is removed). This cannot be undone.

# `permanently_delete_folder`

```elixir
@spec permanently_delete_folder(
  PhoenixKitCatalogue.Schemas.Folder.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Folder.t()} | {:error, term()}
```

Permanently deletes a folder from the database. Non-cascading, matching
the trash/orphan-promotion semantics: direct child folders are promoted
to root (their `parent_uuid` is NULLed) and catalogues filed here are
unfiled (their `folder_uuid` is NULLed) inside the same transaction
before the folder row is removed — so neither is destroyed along with
the folder. This cannot be undone.

# `permanently_delete_item`

```elixir
@spec permanently_delete_item(
  PhoenixKitCatalogue.Schemas.Item.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Item.t()} | {:error, term()}
```

Permanently deletes an item from the database. This cannot be undone.

## Examples

    {:ok, _} = Catalogue.permanently_delete_item(item)

# `permanently_delete_pdf`

# `prune_orphan_pdf_page_contents`

# `put_catalogue_rules`

# `reorder_catalogue_rules`

# `reorder_catalogues`

```elixir
@spec reorder_catalogues(
  [Ecto.UUID.t()],
  keyword()
) :: :ok | {:error, :too_many_uuids | term()}
```

Re-indexes the supplied list of catalogue UUIDs into positions
`1..N`. Used by the catalogues index DnD handler.

UUIDs missing from the table are skipped. The whole pass runs in one
transaction. Returns `:ok` on success or `{:error, reason}` on
transaction failure.

# `reorder_categories`

```elixir
@spec reorder_categories(
  Ecto.UUID.t(),
  Ecto.UUID.t() | nil,
  [Ecto.UUID.t()],
  keyword()
) ::
  :ok | {:error, :not_siblings | :too_many_uuids | term()}
```

Re-indexes a sibling group of categories from a list of UUIDs.

Sibling scope is `(catalogue_uuid, parent_uuid)` — the same scope used
by `swap_category_positions/2` and `next_category_position/2`. The
function loads the supplied categories, verifies they all share that
scope, and writes positions `1..N` in the order given. UUIDs not found
in the table are ignored; UUIDs that don't share the scope abort the
whole batch with `{:error, :not_siblings}`.

Two-pass updates inside a single transaction — the first pass writes
negative positions to dodge any future unique index on
`(catalogue_uuid, parent_uuid, position)`; the second pass writes the
final positive values. If no such index exists today, the cost is one
extra `UPDATE` per row, which is cheap relative to the LV round-trip
that triggers the call.

# `reorder_categories_groups`

```elixir
@spec reorder_categories_groups(
  Ecto.UUID.t(),
  [{Ecto.UUID.t() | nil, [Ecto.UUID.t()]}],
  keyword()
) :: :ok | {:error, :too_many_uuids | :not_siblings | term()}
```

Re-indexes multiple sibling groups of categories in **one outer
transaction** — the LV layer hits this when a single drop touches
more than one parent group.

Each group is `{parent_uuid_or_nil, [uuid]}`. All groups are
validated up front (cap + sibling scope) before any writes; if any
group fails validation, the whole batch returns the error and no
writes happen.

Atomicity: a DB-level failure in any group rolls back every group.
Beats the previous LV-side `Enum.reduce` over per-group calls,
which committed groups one at a time and could leave partial state.

# `reorder_folders`

```elixir
@spec reorder_folders(
  [Ecto.UUID.t()],
  keyword()
) :: :ok | {:error, :too_many_uuids | term()}
```

Re-indexes the supplied folder UUIDs into positions `1..N`. The caller
passes only the UUIDs of one level (same parent); positions are global
integers but the tree groups by `parent_uuid` first, so per-level
`1..N` is correct. UUIDs missing from the table are skipped.

# `reorder_items`

```elixir
@spec reorder_items(Ecto.UUID.t(), Ecto.UUID.t() | nil, [Ecto.UUID.t()], keyword()) ::
  :ok | {:error, :wrong_scope | :too_many_uuids | term()}
```

Re-indexes the items inside a `(catalogue_uuid, category_uuid)`
bucket. Pass `category_uuid: nil` to reorder the uncategorized
bucket. Behaves like `reorder_categories/4`: validates scope, runs
two passes inside a transaction, logs an activity row.

UUIDs that don't belong to the scope abort with
`{:error, :wrong_scope}` so a stale DOM can't bleed reorder writes
across catalogues.

# `reorder_items_by`

```elixir
@spec reorder_items_by(
  Ecto.UUID.t(),
  Ecto.UUID.t() | :uncategorized | nil,
  atom(),
  :all | [Ecto.UUID.t()],
  keyword()
) ::
  :ok
  | {:error,
     :invalid_strategy
     | :duplicate_positions
     | :uuids_outside_scope
     | :too_many_uuids
     | term()}
```

Bulk-reorders the items in one `(catalogue_uuid, category_uuid)` scope
by a strategy (mirrors `PhoenixKitProjects.reorder_tasks_by/3`).

`category_uuid` is normalized via `normalize_category_uuid/1` (`nil` /
`:uncategorized` → the uncategorized scope). `scope`:

  * `:all` — reindex the whole scope `1..N` in strategy order.
  * a list of item UUIDs — permute those rows in place into their own
    (sorted) position slots. Requires distinct positions; otherwise
    `{:error, :duplicate_positions}` (run an `:all` reorder first to
    normalise — catalogue items default to `position: 0`).

Strategies: `:name_asc` / `:name_desc` (raw `name` column),
`:created_asc` / `:created_desc`, `:reverse`.

# `requeue_stuck_extractions`

# `restore_catalogue`

```elixir
@spec restore_catalogue(
  PhoenixKitCatalogue.Schemas.Catalogue.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Catalogue.t()} | {:error, term()}
```

Restores a soft-deleted catalogue by setting its status to `"active"`.

**Cascades downward** in a transaction:
1. All deleted categories → status `"active"`
2. All deleted items in those categories → status `"active"`
3. The catalogue itself → status `"active"`

## Examples

    {:ok, catalogue} = Catalogue.restore_catalogue(catalogue)

# `restore_category`

```elixir
@spec restore_category(
  PhoenixKitCatalogue.Schemas.Category.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Category.t()}
  | {:error, :parent_catalogue_deleted | term()}
```

Restores a soft-deleted category by flipping its status back to
`"active"`. **No cascades** — each entity owns its own status, so
restore-as-undo doesn't ripple sideways.

- **Refuses with `{:error, :parent_catalogue_deleted}`** when the
  category's parent catalogue is itself deleted. The operator must
  restore the catalogue explicitly first.
- **Items keep their (deleted) status.** Items that were trashed via
  the prior `:cascade` disposition stay deleted; the operator restores
  them individually from the Items-tab Deleted view, where
  `restore_item/2` routes them through the now-active parent (or
  detaches them to Uncategorized if some intermediate parent is still
  deleted).
- **Descendant categories keep their (deleted) status.**
  `list_category_tree/2`'s orphan-promotion will surface this re-active
  leaf as a root if all its ancestors are still deleted.
- **Ancestor categories keep their (active or deleted) status.** The
  only ancestor we check is the parent catalogue (above).

Activity log records `category.restored` with `name` and
`catalogue_uuid` only — no `subtree_size` / `items_cascaded`, since
the answer is always 0 under the no-cascade rule.

## Examples

    {:ok, _} = Catalogue.restore_category(category)
    {:error, :parent_catalogue_deleted} =
      Catalogue.restore_category(category_under_deleted_catalogue)

# `restore_folder`

```elixir
@spec restore_folder(
  PhoenixKitCatalogue.Schemas.Folder.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Folder.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Folder.t())}
```

Restores a soft-deleted folder. If its prior parent is gone or still
trashed, the folder is restored to root (appended) so it stays
reachable; otherwise it keeps its parent.

# `restore_item`

```elixir
@spec restore_item(
  PhoenixKitCatalogue.Schemas.Item.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error, :parent_catalogue_deleted | term()}
```

Restores a soft-deleted item by setting its status to `"active"`.

Refuses with `{:error, :parent_catalogue_deleted}` when the item's
parent catalogue is itself deleted — the operator must restore the
catalogue first. (An item cannot exist outside a catalogue.)

When the parent catalogue is active but the item's category is
deleted, the item is **uncategorized on restore**: `category_uuid` is
set to `nil` so the item resurfaces in the catalogue's Uncategorized
bucket. This avoids the surprising side-effect of auto-reviving the
whole category structure. If the user wants the category back, they
restore the category explicitly (which cascades downward and brings
the item with it via `category_uuid` matching).

## Examples

    {:ok, item} = Catalogue.restore_item(item)
    {:error, :parent_catalogue_deleted} =
      Catalogue.restore_item(item_under_deleted_catalogue)

# `restore_pdf`

# `retry_extraction`

# `search_items`

# `search_items_in_catalogue`

# `search_items_in_category`

# `search_pdfs_for_item`

# `set_translation`

# `swap_category_positions`

```elixir
@spec swap_category_positions(
  PhoenixKitCatalogue.Schemas.Category.t(),
  PhoenixKitCatalogue.Schemas.Category.t(),
  keyword()
) :: {:ok, term()} | {:error, :not_siblings | term()}
```

Atomically swaps the positions of two categories within a transaction.

Positions are scoped to `(catalogue_uuid, parent_uuid)` sibling
groups (V103). Swapping positions of categories that are not
siblings would mix two independent ordering axes, so this function
refuses with `{:error, :not_siblings}` when the categories live
under different parents or in different catalogues. The detail-view
reorder buttons enforce the same constraint at the LV level; this
is the context-level guard for any programmatic caller.

## Examples

    {:ok, _} = Catalogue.swap_category_positions(cat_a, cat_b)
    {:error, :not_siblings} = Catalogue.swap_category_positions(root, child)

# `sync_manufacturer_suppliers`

# `sync_supplier_manufacturers`

# `trash_catalogue`

```elixir
@spec trash_catalogue(
  PhoenixKitCatalogue.Schemas.Catalogue.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Catalogue.t()} | {:error, term()}
```

Soft-deletes a catalogue by setting its status to `"deleted"`.

**Cascades downward** in a transaction:
1. All non-deleted items in the catalogue's categories → status `"deleted"`
2. All non-deleted categories → status `"deleted"`
3. The catalogue itself → status `"deleted"`

## Examples

    {:ok, catalogue} = Catalogue.trash_catalogue(catalogue)

# `trash_category`

```elixir
@spec trash_category(
  PhoenixKitCatalogue.Schemas.Category.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Category.t()}
  | {:error, :move_target_not_found | :cross_catalogue_move | term()}
```

Soft-deletes a category and its entire subtree by setting their
status to `"deleted"`.

**Cascades the categories downward** in a transaction (the category
itself and every descendant flip to `"deleted"`), following the V103
nested-category tree.

**Items in the subtree** are handled per the `:items` opt:

  * `:cascade` (default) — items in the subtree flip to `"deleted"`
    alongside the categories. Original behavior, kept for programmatic
    callers + admin "delete and trash everything" intent.
  * `:uncategorize` — items in the subtree keep their `catalogue_uuid`
    but get `category_uuid: nil`, surviving the category trash. Used
    by the admin modal when the operator wants the category gone but
    the items kept in the same catalogue.
  * `{:move_to, target_uuid}` — items move to the target category
    (which must live in the same catalogue) before the category is
    trashed. Cross-catalogue moves aren't supported here; the LV
    restricts the dropdown to same-catalogue targets.

Logs a single `category.trashed` activity on the root with
`subtree_size`, `items_handled`, and `items_disposition` in metadata.

## Examples

    {:ok, _} = Catalogue.trash_category(category)
    {:ok, _} = Catalogue.trash_category(category, items: :uncategorize)
    {:ok, _} = Catalogue.trash_category(category, items: {:move_to, target_uuid})

# `trash_folder`

```elixir
@spec trash_folder(
  PhoenixKitCatalogue.Schemas.Folder.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Folder.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Folder.t())}
```

Soft-deletes a folder (status `"deleted"`). Non-cascading: child
folders and the catalogues filed here keep their `*_uuid`, but
orphan-promote to root in the active tree view. Nothing else changes.

# `trash_item`

```elixir
@spec trash_item(
  PhoenixKitCatalogue.Schemas.Item.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Item.t())}
```

Soft-deletes an item by setting its status to `"deleted"`.

## Examples

    {:ok, item} = Catalogue.trash_item(item)

# `trash_items_in_category`

```elixir
@spec trash_items_in_category(
  Ecto.UUID.t(),
  keyword()
) :: {non_neg_integer(), nil}
```

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)

# `trash_pdf`

# `uncategorized_count_for_catalogue`

```elixir
@spec uncategorized_count_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: non_neg_integer()
```

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.

# `unlink_manufacturer_supplier`

# `update_catalogue`

```elixir
@spec update_catalogue(PhoenixKitCatalogue.Schemas.Catalogue.t(), map(), keyword()) ::
  {:ok, PhoenixKitCatalogue.Schemas.Catalogue.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Catalogue.t())}
```

Updates a catalogue with the given attributes.

# `update_catalogue_rule`

# `update_category`

```elixir
@spec update_category(PhoenixKitCatalogue.Schemas.Category.t(), map(), keyword()) ::
  {:ok, PhoenixKitCatalogue.Schemas.Category.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Category.t())}
```

Updates a category with the given attributes.

# `update_folder`

```elixir
@spec update_folder(PhoenixKitCatalogue.Schemas.Folder.t(), map(), keyword()) ::
  {:ok, PhoenixKitCatalogue.Schemas.Folder.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Folder.t())}
```

Updates a folder's own fields (name/status/data). Parent moves go
through `move_folder/3` — a `parent_uuid` key here is ignored.

# `update_item`

```elixir
@spec update_item(PhoenixKitCatalogue.Schemas.Item.t(), map(), keyword()) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error, Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Item.t())}
```

Updates an item with the given attributes.

# `update_manufacturer`

# `update_supplier`

---

*Consult [api-reference.md](api-reference.md) for complete listing*
