# `PhoenixKitEcommerce.Shopify.CollectionSync`
[🔗](https://github.com/BeamLabEU/phoenix_kit_ecommerce/blob/0.5.11/lib/phoenix_kit_ecommerce/shopify/collection_sync.ex#L1)

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). An
item whose target category the catalogue refuses at assignment time
(trashed, or moved to another catalogue, since this run resolved it)
stays where it is, with a logged warning, instead of failing the whole
run.

A collection with no live category but a TRASHED one matching it (by
the same slug-then-name rule) is skipped the way a filtered-out one is
— logged, counted in `:collections_skipped_trashed`, its products
never fetched, and the survivors re-indexed 0.. again. Re-creating its
category would revive what an operator removed, and cannot work
anyway: the trashed category still holds the handle in the global
slug projection, so the create would collide and halt every run until
the trash is emptied. Restoring the category brings the collection
back on the next run.

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.

# `run`

```elixir
@spec run(keyword()) ::
  {:ok,
   %{
     categories_created: non_neg_integer(),
     categories_matched: non_neg_integer(),
     collections_skipped_by_filter: non_neg_integer(),
     collections_skipped_trashed: 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.

---

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