diff --git a/backend/app/domains/products/dialect_shopify.py b/backend/app/domains/products/dialect_shopify.py new file mode 100644 index 0000000..1ad8b68 --- /dev/null +++ b/backend/app/domains/products/dialect_shopify.py @@ -0,0 +1,71 @@ +"""Shopify product-CSV adapter (INV-17): detect by header signature, map to canonical. + +The mapping is the §6.5.1 contract; the exhaustive table is pinned here and +mirrored by tests/test_products_dialect_shopify.py + e2e/fixtures/shopify-export.csv. +""" +from __future__ import annotations + +from .models import KNOWN_COLUMNS + +# Shopify header -> canonical header (renames only; direct same-name columns pass +# through via KNOWN_COLUMNS). Variant Grams carries a value transform (see codec). +SHOPIFY_RENAME: dict[str, str] = { + "Body (HTML)": "Description", + "Product Category": "Google Product Category", + "Cost per item": "Variant Cost", + "Variant Grams": "Variant Weight", +} + +# Canonical-named columns Shopify uses differently — must NOT pass through. +# Type: Shopify's free-text type vs canonical's structural Type (§6.5.1, decision D). +# Variant Weight Unit: superseded by the Variant Grams -> Variant Weight transform. +SHOPIFY_DROP: frozenset[str] = frozenset({"Type", "Variant Weight Unit"}) + +# Columns whose presence proves the file is a Shopify export (canonical never has them). +_SIGNATURE: frozenset[str] = frozenset( + { + "Body (HTML)", + "Variant Grams", + "Cost per item", + "Variant Compare At Price", + "Variant Inventory Policy", + "Variant Fulfillment Service", + "Variant Requires Shipping", + "Variant Taxable", + "Gift Card", + "SEO Title", + "SEO Description", + } +) + + +def is_shopify_header(header: list[str]) -> bool: + """True iff the header carries a Shopify-only signature column. Otherwise the + file is treated as canonical — shared columns map identically, so an ambiguous + header never misparses (§7.4).""" + cols = {h.strip() for h in header} + if cols & _SIGNATURE: + return True + return any(c.startswith("Google Shopping / ") for c in cols) + + +def map_shopify_header(header: list[str]) -> tuple[list[str | None], list[str]]: + """Return (mapped, not_imported): mapped[i] is the canonical name for header[i], + or None when that Shopify column has no canonical home; not_imported lists the + original Shopify names (in order) that were dropped — the preview warning.""" + mapped: list[str | None] = [] + not_imported: list[str] = [] + for raw in header: + col = raw.strip() + if col in SHOPIFY_RENAME: + mapped.append(SHOPIFY_RENAME[col]) + elif col in SHOPIFY_DROP: + mapped.append(None) + not_imported.append(col) + elif col in KNOWN_COLUMNS: + mapped.append(col) + else: + mapped.append(None) + if col: + not_imported.append(col) + return mapped, not_imported diff --git a/backend/tests/test_products_dialect_shopify.py b/backend/tests/test_products_dialect_shopify.py new file mode 100644 index 0000000..44d7af6 --- /dev/null +++ b/backend/tests/test_products_dialect_shopify.py @@ -0,0 +1,43 @@ +"""Shopify dialect adapter — detection + the §6.5.1 mapping contract (SLICE-8).""" +from app.domains.products.dialect_shopify import is_shopify_header, map_shopify_header + + +def test_detects_shopify_by_signature_column(): + assert is_shopify_header(["Handle", "Title", "Body (HTML)", "Variant Price"]) is True + assert is_shopify_header(["Handle", "Title", "Google Shopping / MPN"]) is True + + +def test_canonical_header_is_not_shopify(): + assert is_shopify_header(["Handle", "Title", "Description", "Variant Cost"]) is False + + +def test_ambiguous_shared_only_header_defaults_canonical(): + # Only columns common to both dialects -> not Shopify (safe default, never misparse). + assert ( + is_shopify_header(["Handle", "Title", "Option1 Name", "Variant Price", "Image Src"]) + is False + ) + + +def test_map_renames_and_passes_through(): + mapped, not_imported = map_shopify_header( + ["Handle", "Body (HTML)", "Cost per item", "Variant Price"] + ) + assert mapped == ["Handle", "Description", "Variant Cost", "Variant Price"] + assert not_imported == [] + + +def test_map_drops_type_and_weight_unit_overrides(): + mapped, not_imported = map_shopify_header( + ["Handle", "Type", "Variant Grams", "Variant Weight Unit"] + ) + assert mapped == ["Handle", None, "Variant Weight", None] + assert not_imported == ["Type", "Variant Weight Unit"] + + +def test_map_drops_shopify_only_and_market_columns(): + mapped, not_imported = map_shopify_header( + ["Handle", "Variant Compare At Price", "Gift Card", "Price / International"] + ) + assert mapped == ["Handle", None, None, None] + assert not_imported == ["Variant Compare At Price", "Gift Card", "Price / International"]