diff --git a/backend/app/domains/products/codec.py b/backend/app/domains/products/codec.py index 52ba6ec..7cc7a9a 100644 --- a/backend/app/domains/products/codec.py +++ b/backend/app/domains/products/codec.py @@ -6,6 +6,7 @@ from __future__ import annotations import csv import io +from .dialect_shopify import is_shopify_header, map_shopify_header from .errors import FileRejected from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row @@ -13,8 +14,8 @@ _REQUIRED_HEADER_COLUMNS = ("Handle", "Title") def detect_dialect(header: list[str]) -> str: - """The INV-17 seam: SLICE-8 recognizes Shopify's exact header set here.""" - return "canonical" + """The INV-17 seam: recognize Shopify's header set, else canonical (§6.5.1).""" + return "shopify" if is_shopify_header(header) else "canonical" def parse_csv(data: bytes) -> ParsedFile: @@ -31,19 +32,34 @@ def parse_csv(data: bytes) -> ParsedFile: except StopIteration: raise FileRejected("not_csv", "This file isn't readable as CSV.") from None header = [h.strip() for h in raw_header] + dialect = detect_dialect(header) + + # INV-17: normalize the header to canonical names at the boundary. mapped[i] + # is the canonical name for header[i] (or None when that column has no + # canonical home); unknown is the not-imported warning list. Canonical files + # map to themselves; unknown columns are warned exactly as before. + if dialect == "shopify": + mapped, unknown = map_shopify_header(header) + else: + mapped = [c if c in KNOWN_COLUMNS else None for c in header] + unknown = [c for c in header if c and c not in KNOWN_COLUMNS] + for col in _REQUIRED_HEADER_COLUMNS: - if col not in header: + if col not in mapped: raise FileRejected( "missing_required_column", f"This file is missing the required column '{col}'.", ) - # First occurrence of a duplicated column wins. + + # First occurrence of a duplicated canonical column wins. col_index: dict[str, int] = {} - for i, name in enumerate(header): + for i, name in enumerate(mapped): if name and name not in col_index: col_index[name] = i - known_present = [c for c in col_index if c in KNOWN_COLUMNS] - unknown = [c for c in col_index if c not in KNOWN_COLUMNS] + # De-dup the warning list, order-preserving. + seen: set[str] = set() + unknown = [c for c in unknown if not (c in seen or seen.add(c))] + rows: list[Row] = [] for raw in reader: if not any(cell.strip() for cell in raw): @@ -55,11 +71,12 @@ def parse_csv(data: bytes) -> ParsedFile: ) cells = { c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "") - for c in known_present + for c in col_index } + # Shopify grams carry an implicit unit; canonical needs it explicit (§6.5.1). + if dialect == "shopify" and cells.get("Variant Weight"): + cells["Variant Weight Unit"] = "g" rows.append(Row(line_number=reader.line_num, cells=cells)) except csv.Error: raise FileRejected("not_csv", "This file isn't readable as CSV.") from None - return ParsedFile( - dialect=detect_dialect(header), header=header, unknown_columns=unknown, rows=rows - ) + return ParsedFile(dialect=dialect, header=header, unknown_columns=unknown, rows=rows) diff --git a/backend/tests/test_products_codec.py b/backend/tests/test_products_codec.py index 4880f5c..d1b82b2 100644 --- a/backend/tests/test_products_codec.py +++ b/backend/tests/test_products_codec.py @@ -67,3 +67,29 @@ def test_missing_column_message_names_the_column(): with pytest.raises(FileRejected) as exc: parse_csv(_csv("Handle,Vendor", "mug,Acme")) assert "'Title'" in exc.value.message + + +def test_parse_detects_and_maps_shopify(): + data = ( + "Handle,Title,Body (HTML),Cost per item,Variant Price,Variant Grams,Type,Gift Card\n" + "mug,Moon Mug,

Grey

,9.50,18.00,300,Drinkware,false\n" + ).encode("utf-8") + parsed = parse_csv(data) + assert parsed.dialect == "shopify" + cells = parsed.rows[0].cells + assert cells["Description"] == "

Grey

" + assert cells["Variant Cost"] == "9.50" + assert cells["Variant Weight"] == "300" + assert cells["Variant Weight Unit"] == "g" # synthesized + # Type (free-text) and Gift Card warned, never mapped: + assert "Type" in parsed.unknown_columns + assert "Gift Card" in parsed.unknown_columns + assert "product_type" not in str(cells) # Type never reached canonical + + +def test_parse_canonical_unchanged(): + data = b"Handle,Title,Description,Variant Price\nmug,Moon Mug,Grey,18.00\n" + parsed = parse_csv(data) + assert parsed.dialect == "canonical" + assert parsed.rows[0].cells["Description"] == "Grey" + assert parsed.unknown_columns == []