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.
Summary
Functions
True when the CRM module is loaded and exposes the party API this bridge needs.
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.
Manufacturer counterpart of link_supplier/3 — grants the manufacturer role.
Links a supplier to a CRM company: grants the supplier role and stamps the
cross-reference, in one transaction.
CRM companies offered as link targets, as {label, uuid} pairs.
Normalizes whatever CRM's option source returns into {label, value} pairs.
Resolves a party BY NAME, creating the company when there is no match, and
grants it role. Returns {:ok, company_uuid}.
Manufacturer counterpart of unlink_supplier/2.
Clears a supplier's CRM cross-reference. Its own name and website become the identity again — no data moves, because none was copied.
Functions
@spec available?() :: boolean()
True when the CRM module is loaded and exposes the party API this bridge needs.
@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.
@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.
@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.
@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.
@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.
@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.
@spec unlink_manufacturer( PhoenixKitCatalogue.Schemas.Manufacturer.t(), keyword() ) :: {:ok, PhoenixKitCatalogue.Schemas.Manufacturer.t()} | {:error, :stale | Ecto.Changeset.t()}
Manufacturer counterpart of unlink_supplier/2.
@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.