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

Context for managing the `phoenix_kit_cat_item_supplier_info` junction table.

Each row links a catalogue item to a supplier (local or CRM-sourced) and
carries a snapshot of the supplier's name at write time so the record stays
readable even after renaming or deletion.

At most one row per item may have `is_primary: true` — enforced by a partial
unique index and by `set_primary/1`, which uses a transaction to clear any
existing primary before promoting the target row.

## Price history via validity windows

The schema carries `valid_from`/`valid_to` date fields. A row is considered
*current* when `valid_to` is `nil`. `revise_unit_cost/3` implements a
non-destructive price revision: the current row is closed (`valid_to: today`,
`is_primary: false`) and a successor row is inserted with the new cost,
`valid_from: today`, and `valid_to: nil`. This produces an append-only price
history. The canonical "current" predicate is `is_nil(valid_to)`, and that is
also what `history_for_pair/2` orders on first — see the note there.

# `cost_ranges`

```elixir
@spec cost_ranges([Ecto.UUID.t()]) :: %{
  required(Ecto.UUID.t()) =&gt; [
    %{
      currency: String.t() | nil,
      min: Decimal.t(),
      max: Decimal.t(),
      count: pos_integer()
    }
  ]
}
```

Supplier cost ranges for a batch of items, from their CURRENT rows
(`valid_to` nil) that carry a unit cost: `%{item_uuid => [range]}` with
one range per currency — `%{currency: "EUR" | nil, min: Decimal,
max: Decimal, count: n}`, cheapest currency group first. Items without
a priced supplier are absent. One query however many items.

# `create`

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

Creates a supplier-info row.

When the item has no primary supplier yet, the newly linked row is
auto-promoted to primary (an item with suppliers but no primary is a
valid state only when the user explicitly demotes it).

# `delete`

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

Removes a supplier from its item.

The current row is CLOSED (`valid_to` today, primary flag dropped), not
deleted — exactly what a price revision does to the row it supersedes.
Every "current" reader filters on `valid_to`, so the supplier disappears
from the item everywhere; `history_for_pair/2` keeps the row, which is
what carries the pair's comment thread so re-attaching the supplier
resumes it. Only the current row can be removed: `{:error, :not_current}`
for one already closed.

# `get`

```elixir
@spec get(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.ItemSupplierInfo.t() | nil
```

Fetches a supplier-info row by UUID. Returns `nil` if not found.

# `history_for_pair`

```elixir
@spec history_for_pair(Ecto.UUID.t(), Ecto.UUID.t()) :: [
  PhoenixKitCatalogue.Schemas.ItemSupplierInfo.t()
]
```

Returns all rows for an item/supplier pair ordered newest-first.

Includes both current (`valid_to: nil`) and closed rows so callers can
display a full price revision history. The current row is always first;
closed rows follow, most recently closed first.

# `list_for_item`

```elixir
@spec list_for_item(Ecto.UUID.t()) :: [
  PhoenixKitCatalogue.Schemas.ItemSupplierInfo.t()
]
```

Lists *current* supplier-info rows for an item, ordered by position then
inserted_at.

A row is current when `valid_to` is `nil`. Closed rows (produced by
`revise_unit_cost/3`) are excluded; use `history_for_pair/2` to retrieve the
full history for a specific item/supplier pair.

# `primary_for_item`

```elixir
@spec primary_for_item(Ecto.UUID.t()) ::
  PhoenixKitCatalogue.Schemas.ItemSupplierInfo.t() | nil
```

Returns the *current* primary supplier-info row for an item, or `nil` if
none is marked primary.

Only current rows (`valid_to: nil`) are considered — closed revision rows
carry `is_primary: false` by construction and are excluded.

# `revise_unit_cost`

```elixir
@spec revise_unit_cost(
  PhoenixKitCatalogue.Schemas.ItemSupplierInfo.t(),
  Decimal.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.ItemSupplierInfo.t()}
  | {:error, :not_current | Ecto.Changeset.t()}
```

Closes the current junction row and inserts a successor with the new unit
cost, creating an append-only price revision history.

## Guards

  * `{:error, :not_current}` — `info.valid_to` is not `nil`; only the current
    row can be revised.
  * `{:ok, info}` (no-op) — `new_cost` equals `info.unit_cost` (or both
    effectively zero when `unit_cost` is `nil`), *and* `opts[:currency]` is
    either absent or matches the row's current currency. A currency-only
    change (same cost, different `:currency`) still creates a revision.

## Transaction

Within a single Ecto.Multi transaction:

1. The current row is closed: `valid_to` is set to today and `is_primary` is
   forced to `false` via `force_change/3`, freeing the partial-unique index
   inside the tx so the successor can inherit the primary flag.
2. A successor row is inserted copying `item_uuid`, `supplier_uuid`,
   `supplier_source`, `supplier_name_snapshot`, `supplier_sku`, `currency`,
   `lead_time_days`, `min_order_qty`, `position`, and `metadata` from the
   closed row. `unit_cost` is set to `new_cost`; `valid_from` is today;
   `valid_to` is `nil`; `is_primary` inherits the original row's value.

## Options

  * `:actor_uuid` — UUID of the user performing the revision (persisted in the
    activity log).
  * `:source` — free-form source label (e.g. `"goods_receipt"`).
  * `:source_uuid` — UUID of the originating resource (e.g. receipt UUID).
  * `:currency` — when provided and different from the row's currency, the
    successor stores the new currency; both old and new currencies are
    recorded in the activity metadata.

## Currency awareness

The successor always keeps the row's own currency unless `opts[:currency]` is
given and differs, in which case the new currency is stored and the activity
log captures both.

# `set_primary`

```elixir
@spec set_primary(PhoenixKitCatalogue.Schemas.ItemSupplierInfo.t(), keyword()) ::
  {:ok, PhoenixKitCatalogue.Schemas.ItemSupplierInfo.t()} | {:error, any()}
```

Promotes a supplier-info row to primary for its item.

Runs in a transaction: clears `is_primary` on all sibling rows first,
then sets `is_primary: true` on the target. Respects the partial unique
index — concurrent callers produce a constraint violation rather than
double-marking.

# `update`

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

Updates a supplier-info row.

The row's comment thread is re-stamped from the row itself on every
update, so a caller replacing `metadata` wholesale (the custom-field save
does) cannot drop it, and attrs cannot change it.

---

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