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

Links a catalogue directory row to the CRM party it represents.

There is no UI for linking any more: suppliers and manufacturers are created
and managed in CRM, and the item form picks them from there directly. What
remains is the migration path for rows that predate the move —
`mix phoenix_kit_crm.import_suppliers_from_catalogue` and anything else that
needs to attach an existing local row to its party.

This module also owns the other direction — `resolve_or_create_company/3`,
which turns a NAME into a party. The importer is its caller: a spreadsheet
column carries a name, and the catalogue no longer owns supplier or
manufacturer identity, so that name has to land in CRM. Local directory rows
are created only when CRM is absent.

CRM owns party identity: "supplier" and "manufacturer" are *roles* on a CRM
company or contact. The catalogue's local `phoenix_kit_cat_suppliers` /
`phoenix_kit_cat_manufacturers` rows are kept — catalogue-standalone installs
have no CRM, `phoenix_kit_cat_manufacturer_suppliers` carries hard FKs onto
the manufacturer row, and `logo_url`, notes and catalogue status have no home
in CRM — but once linked they stop being an identity of their own.

## Linking copies nothing

A link writes exactly one column: `crm_company_uuid`. It does not copy the
party's name, website or contact details onto the local row, because
`Suppliers.resolve/1` and `Manufacturers.resolve/1` **resolve through the
xref**: a stored reference to the local uuid returns the party's CURRENT
identity. Every existing reference — junction rows, warehouse documents —
goes live the moment the row is linked, with no data rewritten and nothing
to keep in sync afterwards.

That is why there is no "refresh" here. There is nothing to refresh.

## Ordering and atomicity

Granting the role and stamping the xref happen in ONE transaction, and a
failed grant fails the whole link. Both halves are load-bearing: the
resolvers key on the ACTIVE ROLE, not on the xref column, so an xref stamped
against a party that does not carry the role resolves to nothing — and the
row would then be hidden from the directory as "linked" while resolving to
nothing, i.e. silently disappear.

Every CRM call is guarded with `Code.ensure_loaded?` + `function_exported?`
and cannot raise out of this module: CRM is an optional runtime dependency.

# `available?`

```elixir
@spec available?() :: boolean()
```

True when the CRM module is loaded and exposes the party API this bridge needs.

# `can_provision?`

```elixir
@spec can_provision?() :: boolean()
```

True when CRM can additionally PROVISION a company, not just read one —
a narrower guard than `available?/0`, which only covers linking an
existing party.

# `link_manufacturer`

```elixir
@spec link_manufacturer(
  PhoenixKitCatalogue.Schemas.Manufacturer.t(),
  Ecto.UUID.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Manufacturer.t()} | {:error, link_error()}
```

Manufacturer counterpart of `link_supplier/3` — grants the `manufacturer` role.

# `link_supplier`

```elixir
@spec link_supplier(
  PhoenixKitCatalogue.Schemas.Supplier.t(),
  Ecto.UUID.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Supplier.t()} | {:error, link_error()}
```

Links a supplier to a CRM company: grants the `supplier` role and stamps the
cross-reference, in one transaction.

Errors: `:crm_unavailable` (module absent), `:company_not_found`,
`:role_grant_failed` (the party could not be given the role — the link is
abandoned rather than left resolving to nothing), `:stale` (the row was
linked or relinked by someone else since it was loaded), or a changeset when
the company already projects a different supplier.

# `list_candidates`

```elixir
@spec list_candidates() :: [{String.t(), Ecto.UUID.t()}]
```

CRM companies offered as link targets, as `{label, uuid}` pairs.

Every company is a candidate, not only those already holding the role —
linking is how a company *acquires* the role. Returns `[]` when CRM is absent.

# `normalize_candidates`

```elixir
@spec normalize_candidates([term()]) :: [{String.t(), Ecto.UUID.t()}]
```

Normalizes whatever CRM's option source returns into `{label, value}` pairs.

CRM hands back `%{label:, value:}` today; the tuple shape is accepted too and
anything unrecognized is dropped. Public so it can be tested without CRM
installed — this is a cross-module shape assumption, and the version that
assumed tuples rendered a picker with zero options and no error anywhere,
because a comprehension silently drops elements that don't match its pattern.

# `resolve_or_create_company`

```elixir
@spec resolve_or_create_company(String.t(), String.t(), keyword()) ::
  {:ok, Ecto.UUID.t()} | :unavailable | {:error, term()}
```

Resolves a party BY NAME, creating the company when there is no match, and
grants it `role`. Returns `{:ok, company_uuid}`.

This is the importer's entry point. An import column carries a name, not a
uuid, and the catalogue no longer owns supplier or manufacturer identity —
so a name that CRM already knows must attach to that company rather than
minting a second record for the same business.

`:unavailable` when CRM is absent, which is a supported install: the caller
falls back to a local directory row. `{:error, {:ambiguous_name, uuids}}` when
two live companies share the name — picking one by list order would attach the
reference to an arbitrary legal entity while looking like it worked.

Creating the company and granting the role happen in ONE transaction: the
resolvers key on the active role, so a company created without one is
invisible to every picker.

## Matching

Trimmed, case-insensitive, exact. CRM's own search is an ILIKE *contains*
match, so "Nordic" would otherwise adopt "Nordic Hardware Supply OY" — the
candidates come from that search but the equality check is done here. Two
genuinely different companies whose names differ only in case are treated
as one; that is the deliberate trade, because importing `ACME` beside
`Acme` as separate suppliers is the worse failure and the harder one to
notice.

# `unlink_manufacturer`

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

Manufacturer counterpart of `unlink_supplier/2`.

# `unlink_supplier`

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

Clears a supplier's CRM cross-reference. Its own name and website become the
identity again — no data moves, because none was copied.

The party role is deliberately left in place: the role's lifecycle belongs to
CRM, the company may hold it independently of whether this row projects it,
and revoking it from here would reach across the boundary this design exists
to keep. It also means a re-link is a single stamp.

---

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