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
@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 implementingfetch_collections/1andfetch_collection_product_ids/2; defaults toAdminClient.:catalogue_uuid— required.:filter—%{"prefix" => handle_prefix | nil, "exclude" => [handle]}; defaults toPhoenixKitEcommerce.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.