PhoenixKitEcommerce.Shopify.CollectionSync (PhoenixKitEcommerce v0.5.0)

Copy Markdown View Source

Maps Shopify collections onto catalogue categories, preserving both orders — Block 7 Task 4 (docs/superpowers/plans/2026-09-06-block7- shopify-media-collections.md, §5 Блок 7 in the design spec), with the live-store allowlist and most-specific assignment added by Block 7b Task 2 (docs/superpowers/plans/2026-09-06-block7b-shopify-live-fixes.md) — the real store has 143 collections, of which only ~12 3d-printed-* ones are our categories; the rest (an "all products", a curated cross-category "featured" mix, per-tag collections, ...) must never become categories or move an item at all.

run/1 fetches every collection via opts[:client] (a module exposing fetch_collections/1/fetch_collection_product_ids/2, defaulting to PhoenixKitEcommerce.Shopify.AdminClient — both opts keyword lists are forwarded to it as-is, so :integration_uuid/ :req_options reach the real client the same way they reach AdminClient.fetch_products/2), then drops every collection that fails opts[:filter] (default: PhoenixKitEcommerce.get_config/1's "shopify_collections_filter", itself defaulting to %{} — everything passes) — %{"prefix" => handle_prefix | nil, "exclude" => [handle]}; a collection is allowed when its handle starts with prefix (or prefix is nil/"") AND isn't in exclude. A filtered-out collection is skipped ENTIRELY: no category is matched/created/repositioned for it, its products are never fetched, and it can never be "the" category an item is assigned to — it is simply invisible to every phase below, counted only in :collections_skipped_by_filter. The surviving collections are RE-INDEXED 0.. in their own (filtered) API order before anything else runs, so a category's position never carries gaps left by the collections the filter removed.

Each surviving collection resolves to a catalogue category (match by slug[primary] == handle, then by name case-insensitive, else create — Shopify collections are flat, so a created category's parent_uuid is always nil; the existing tree is never touched), and writes category.position = the collection's (re-indexed) position plus category.data["ecommerce"]["shopify"]["collection_id"].

Then every resolved collection's product ids (fetch_collection_product_ids/2 — already in the collection's own sort order) are fetched, and each distinct product id is matched to a catalogue item by data["ecommerce"]["shopify"]["product_id"] and assigned to the MOST SPECIFIC of its own (allowed) collections — the one with the fewest products, ties broken by (filtered) API order — at that collection's list position for the item. An item listed in only one allowed collection trivially gets that one. This replaces Block 7's original "keep the item's current category when Shopify still lists it there" rule outright: on the live store, several allowed categories can legitimately list the same item (e.g. a general "3d-printed-decor" alongside a narrower "3d-printed-wall-frames"), and always preferring whichever one happened to be assigned first — rather than the narrowest match — is the bug this task fixes. A product id with no matching item is collected into :unmatched_products instead (deduplicated — the same missing id is never reported twice even if more than one collection lists it).

A no-op — {:error, :catalogue_source_inactive} — when ProductSource.current/0 isn't Catalogue (Global Constraints: every new Block 7 writer is legacy-source-safe on its own).

opts[:catalogue_uuid] is required — unlike Writer's functions, which resolve the shop's one catalogue internally, this module takes it explicitly so a caller (the Task 5 worker, or a test) controls exactly which catalogue is touched.

Categories with no matching Shopify collection at all (e.g. a manually-curated category the store never modeled as a collection) are never looked at here, let alone modified.

Summary

Functions

See the moduledoc. opts

Functions

run(opts \\ [])

@spec run(keyword()) ::
  {:ok,
   %{
     categories_created: non_neg_integer(),
     categories_matched: non_neg_integer(),
     collections_skipped_by_filter: non_neg_integer(),
     items_assigned: non_neg_integer(),
     items_repositioned: non_neg_integer(),
     unmatched_products: [term()]
   }}
  | {:error, :catalogue_source_inactive | :missing_catalogue_uuid | term()}

See the moduledoc. opts:

  • :client — module implementing fetch_collections/1 and fetch_collection_product_ids/2; defaults to AdminClient.
  • :catalogue_uuid — required.
  • :filter%{"prefix" => handle_prefix | nil, "exclude" => [handle]}; defaults to PhoenixKitEcommerce.get_config/1's "shopify_collections_filter" (itself %{} — everything passes — when never configured).

  • anything else (:integration_uuid, :req_options, ...) is forwarded to the client calls unchanged.