PhoenixKitEcommerce.Shopify.VariantMapper (PhoenixKitEcommerce v0.5.10)

Copy Markdown View Source

Pure mapping from a Shopify Admin API product payload's "options"/ "variants" into the shape PhoenixKitEcommerce.Catalogue.Writer. sync_variants/2 attaches as catalogue attribute sets: one set per real option (Shopify's auto-generated single-option "no variants" product — option name "Title", its lone value "Default Title" — is skipped, same as it never becomes a _option_slots entry on the legacy CSV path), values in the order they first appear across variants[], and a per-value price modifier.

No I/O, no catalogue/entities lookups — build/1 never needs a live set to exist. slug (both the set's and each modifier map's key) is PhoenixKitEcommerce.Catalogue.SetSlug.normalise/1 of the option's OWN name; resolving a raw variant-option VALUE label to a value slug is a separate, stateful step (PhoenixKitEcommerce.Catalogue. ValueResolver) Writer.sync_variants/2 runs afterward, against whatever catalogue set that slug now names.

Modifier rule

For each option, group variants[] by that option's value (variant["option<position>"]); a value's modifier is min(price of variants carrying that value) − min(price of every priced variant on the product). Grouping by MIN rather than "first variant seen" (the legacy Import.OptionBuilder's rule, correct only when a single option drives price) matters here because two options combine on one variant — the cheapest variant carrying value X is the fair anchor for X's modifier regardless of what its OTHER option happened to be. The overall cheapest value's modifier is exactly Decimal.new("0.00") (equal minima, not a rounded near-zero) as long as Shopify's own price strings carry two decimals, which Decimal.sub/2 preserves.

Non-additive matrices are reported, not hidden

Per-option modifiers can only ever express an ADDITIVE price matrix: the storefront prices a selection as base + Σ modifier(value). A Shopify matrix that is not additive — S/L × Red/Blue at 10/12/15/20 gives S:0, L:5, Red:0, Blue:2 and predicts 17 for L-Blue, where Shopify charges 20 — is silently under-priced by that reconstruction. build/1 therefore re-derives every priced variant's price from the modifiers it just computed and lists each mismatch in :warnings (one human-readable line per variant, naming the product), logging each at :warning as well. The modifiers are still returned — they are the best additive fit — but a caller writing them must surface the warnings (the media sync worker records them on the run's per-product errors) rather than let the write pass as clean.

Summary

Functions

Builds %{sets: [...], modifiers: %{set_slug => %{label => Decimal}}, warnings: [...]} from shopify_product's "options" and "variants". Both keys default to [] when absent (a payload with no options at all — every variant on the default "Title" option — yields sets: [], modifiers: %{}, warnings: []). See the moduledoc for what :warnings carries.

Types

set()

@type set() :: %{
  name: String.t(),
  slug: String.t(),
  values: [String.t()],
  position: pos_integer()
}

t()

@type t() :: %{
  sets: [set()],
  modifiers: %{required(String.t()) => %{required(String.t()) => Decimal.t()}},
  warnings: [String.t()]
}

Functions

build(shopify_product)

@spec build(map()) :: t()

Builds %{sets: [...], modifiers: %{set_slug => %{label => Decimal}}, warnings: [...]} from shopify_product's "options" and "variants". Both keys default to [] when absent (a payload with no options at all — every variant on the default "Title" option — yields sets: [], modifiers: %{}, warnings: []). See the moduledoc for what :warnings carries.