PhoenixKitCatalogue.Catalogue.CrmLink (PhoenixKitCatalogue v0.18.0)

Copy Markdown View Source

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

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

Functions

available?()

@spec available?() :: boolean()

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

can_provision?()

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

list_candidates()

@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(options)

@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(name, role, opts \\ [])

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