From 9bc6e4dbd237865a91404474df4e606acaf04d58 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 15:28:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(products):=20diff=20engine=20=E2=80=94=20a?= =?UTF-8?q?dd/update/unchanged/error=20+=20INV-11=20fingerprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/app/domains/products/diff.py | 207 +++++++++++++++++++++++ backend/app/domains/products/validate.py | 6 +- backend/tests/test_products_diff.py | 110 ++++++++++++ 3 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 backend/app/domains/products/diff.py create mode 100644 backend/tests/test_products_diff.py diff --git a/backend/app/domains/products/diff.py b/backend/app/domains/products/diff.py new file mode 100644 index 0000000..ddd94b0 --- /dev/null +++ b/backend/app/domains/products/diff.py @@ -0,0 +1,207 @@ +"""Diff engine — catalog × canonical products → preview records (SD-0002 §6.5.2). + +Classifies each canonical product against the storefront's current catalog as +add / update / unchanged / error, producing JSON-ready preview records (stored +as draft JSONB, served verbatim to the SPA) plus a summary and a deterministic +fingerprint that detects catalog drift between preview and confirm (INV-11). + +Deliberately DB-free: the catalog snapshot dataclasses are defined here and the +repo layer builds them. Only fields present in the file's canonical fields{} +participate in a comparison — an absent column is untouched, never a change +(§6.5.1); a present-but-empty cell resolves to the field's CLEAR_DEFAULTS entry. +Catalog variants/images absent from the file are likewise untouched (INV-10). +""" +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from decimal import Decimal + +from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant + + +@dataclass +class CatalogVariant: + id: int + options: tuple[str | None, str | None, str | None] + position: int + # sku, barcode, price (Decimal|None), cost, weight, weight_unit, volume, + # volume_unit, tax_id_1, tax_id_2, inventory_tracker, inventory_qty, + # variant_image (linked image source_url or None) + fields: dict[str, object] + + +@dataclass +class CatalogImage: + id: int + source_url: str + position: int + alt_text: str | None + + +@dataclass +class CatalogProduct: + id: int + handle: str + title: str + option_names: tuple[str | None, str | None, str | None] + # title, description_html, vendor, product_type, google_product_category, + # tags (list[str]), status, published (bool) + fields: dict[str, object] + variants: list[CatalogVariant] + images: list[CatalogImage] + + +@dataclass(frozen=True) +class DiffResult: + records: list[dict] + summary: dict + fingerprint: str + + +# Option-name presence markers in fields{} (see validate.py); the values are +# compared via the option_names attribute, not through the generic field loop. +_OPTION_NAME_FIELDS = ("option1_name", "option2_name", "option3_name") +_SUMMARY_KEY = {"add": "adds", "update": "updates", "unchanged": "unchanged", "error": "errors"} + + +def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalProduct]) -> DiffResult: + records: list[dict] = [] + summary = {"adds": 0, "updates": 0, "unchanged": 0, "errors": 0} + for product in products: + current = catalog.get(product.handle) + if product.errors: + kind, detail = "error", {"errors": [e.as_json() for e in product.errors]} + elif current is None: + kind, detail = "add", _add_detail(product) + else: + detail = _update_detail(product, current) + kind = "update" if detail else "unchanged" + records.append( + { + "handle": product.handle, + "title": product.title or (current.title if current else ""), + "kind": kind, + "variant_count": len(current.variants) if kind == "unchanged" else len(product.variants), + "detail": detail, + } + ) + summary[_SUMMARY_KEY[kind]] += 1 + fingerprint = hashlib.sha256( + json.dumps(records, sort_keys=True, separators=(",", ":"), default=str).encode() + ).hexdigest() + return DiffResult(records=records, summary=summary, fingerprint=fingerprint) + + +def _add_detail(product: CanonicalProduct) -> dict: + # On add, absent fields fall back to their defaults for display where one + # exists — the detail shows what will actually be set. + set_fields = _resolved_product_fields(product) + for field_name, default in CLEAR_DEFAULTS.items(): + set_fields.setdefault(field_name, default) + return { + "set": {f: _json_safe(v) for f, v in set_fields.items()}, + "option_names": list(product.option_names), + "variants": [ + {"options": list(v.options), "set": _resolved_variant_fields(v)} + for v in product.variants + ], + "images": [ + {"src": i.source_url, "position": i.position, "alt_text": i.alt_text} + for i in product.images + ], + } + + +def _update_detail(product: CanonicalProduct, current: CatalogProduct) -> dict: + changes: list[dict] = [] + # Title is always file-present (required header column); "" means the block + # already carries an error and never reaches here. + if product.title and product.title != current.title: + changes.append(_change("title", current.title, product.title)) + for field_name, resolved in _resolved_product_fields(product).items(): + before = current.fields.get(field_name) + if resolved != before: + changes.append(_change(field_name, before, resolved)) + for slot in (1, 2, 3): + if f"option{slot}_name" not in product.fields: + continue + before, after = current.option_names[slot - 1], product.option_names[slot - 1] + if after != before: + changes.append(_change(f"option{slot}_name", before, after)) + + variant_entries: list[dict] = [] + by_options = {v.options: v for v in current.variants} + for variant in product.variants: + match = by_options.get(variant.options) + if match is None: + variant_entries.append( + {"options": list(variant.options), "kind": "add", "set": _resolved_variant_fields(variant)} + ) + continue + variant_changes = [ + _change(f, match.fields.get(f), resolved) + for f, resolved in _resolved_fields(variant.fields).items() + if resolved != match.fields.get(f) + ] + if variant_changes: + variant_entries.append( + {"options": list(variant.options), "kind": "update", "changes": variant_changes} + ) + + image_entries: list[dict] = [] + by_src = {i.source_url: i for i in current.images} + for image in product.images: + match = by_src.get(image.source_url) + if match is None: + image_entries.append( + {"src": image.source_url, "kind": "add", "position": image.position, "alt_text": image.alt_text} + ) + continue + image_changes = [ + _change(f, before, after) + for f, before, after in ( + ("position", match.position, image.position), + ("alt_text", match.alt_text, image.alt_text), + ) + if after != before + ] + if image_changes: + image_entries.append({"src": image.source_url, "kind": "update", "changes": image_changes}) + + detail: dict = {} + if changes: + detail["changes"] = changes + if variant_entries: + detail["variants"] = variant_entries + if image_entries: + detail["images"] = image_entries + return detail + + +def _resolved_fields(fields: dict[str, object]) -> dict[str, object]: + """File-present fields with None (an explicit clear) resolved to the default.""" + return {f: (v if v is not None else CLEAR_DEFAULTS.get(f)) for f, v in fields.items()} + + +def _resolved_product_fields(product: CanonicalProduct) -> dict[str, object]: + return { + f: v for f, v in _resolved_fields(product.fields).items() if f not in _OPTION_NAME_FIELDS + } + + +def _resolved_variant_fields(variant: CanonicalVariant) -> dict[str, object]: + return {f: _json_safe(v) for f, v in _resolved_fields(variant.fields).items()} + + +def _change(field_name: str, before: object, after: object) -> dict: + return {"field": field_name, "before": _json_safe(before), "after": _json_safe(after)} + + +def _json_safe(value: object) -> object: + if isinstance(value, Decimal): + return str(value) + if isinstance(value, (tuple, list)): + return [_json_safe(v) for v in value] + return value diff --git a/backend/app/domains/products/validate.py b/backend/app/domains/products/validate.py index 22e0a64..791a934 100644 --- a/backend/app/domains/products/validate.py +++ b/backend/app/domains/products/validate.py @@ -31,8 +31,10 @@ from .models import ( _HANDLE_RE = re.compile(r"^[a-z0-9-]+$") _STATUSES = {"draft", "active", "archived"} -# Product columns handled as attributes (title, option_names), not via fields{}. -_ATTRIBUTE_COLUMNS = {"Title", "Option1 Name", "Option2 Name", "Option3 Name"} +# Title is handled as an attribute, not via fields{}. Option names live in BOTH +# .option_names (the values) and fields{} (the file-presence signal the diff +# needs: absent column == untouched, never a clear). +_ATTRIBUTE_COLUMNS = {"Title"} # Sentinel: the cell failed normalization (the error is already recorded). _INVALID = object() diff --git a/backend/tests/test_products_diff.py b/backend/tests/test_products_diff.py new file mode 100644 index 0000000..4421d51 --- /dev/null +++ b/backend/tests/test_products_diff.py @@ -0,0 +1,110 @@ +"""Diff engine — classification, blank-vs-absent, option matching, fingerprint (§6.8).""" +from decimal import Decimal + +from app.domains.products.codec import parse_csv +from app.domains.products.diff import ( + CatalogImage, CatalogProduct, CatalogVariant, compute_diff, +) +from app.domains.products.validate import build_products + + +def _canon(*lines: str): + return build_products(parse_csv(("\n".join(lines) + "\n").encode())) + + +def _catalog_mug(**overrides): + fields = { + "title": "Moon Mug", "description_html": None, "vendor": "Acme", + "product_type": "standalone", "google_product_category": None, + "tags": ["kitchen"], "status": "active", "published": True, + } | overrides + return { + "moon-mug": CatalogProduct( + id=1, handle="moon-mug", title=fields["title"], + option_names=(None, None, None), fields=fields, + variants=[CatalogVariant(id=10, options=(None, None, None), position=1, + fields={"sku": "SKU-1", "barcode": None, "price": Decimal("18.00"), + "cost": None, "weight": None, "weight_unit": None, + "volume": None, "volume_unit": None, "tax_id_1": None, + "tax_id_2": None, "inventory_tracker": None, + "inventory_qty": 40, "variant_image": None})], + images=[CatalogImage(id=100, source_url="https://x/a.jpg", position=1, alt_text=None)], + ) + } + + +HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src" +MUG_ROW = 'moon-mug,Moon Mug,Acme,kitchen,active,SKU-1,18.00,40,https://x/a.jpg' + + +def test_new_handle_classifies_add(): + diff = compute_diff({}, _canon(HEADER, MUG_ROW)) + [rec] = diff.records + assert rec["kind"] == "add" and rec["handle"] == "moon-mug" and rec["variant_count"] == 1 + assert diff.summary == {"adds": 1, "updates": 0, "unchanged": 0, "errors": 0} + assert rec["detail"]["set"]["status"] == "active" + + +def test_identical_file_classifies_unchanged(): + diff = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW)) + assert diff.records[0]["kind"] == "unchanged" + assert diff.summary["unchanged"] == 1 + + +def test_changed_price_classifies_update_with_before_after(): + row = MUG_ROW.replace("18.00", "21.00") + diff = compute_diff(_catalog_mug(), _canon(HEADER, row)) + [rec] = diff.records + assert rec["kind"] == "update" + [vchange] = rec["detail"]["variants"] + assert {"field": "price", "before": "18.00", "after": "21.00"} in vchange["changes"] + + +def test_absent_column_untouched_empty_cell_clears(): + # Vendor column absent: vendor stays Acme. Status present-but-empty: clears to default 'active' (already active -> no change). + diff = compute_diff( + _catalog_mug(), + _canon("Handle,Title,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src", + "moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"), + ) + assert diff.records[0]["kind"] == "unchanged" + + +def test_empty_cell_clear_shows_in_diff(): + # Vendor present-but-empty clears Acme -> None: an explicit, previewable change (§6.5.1). + diff = compute_diff( + _catalog_mug(), + _canon("Handle,Title,Vendor,Variant SKU,Variant Price,Variant Inventory Qty,Image Src", + "moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"), + ) + [rec] = diff.records + assert rec["kind"] == "update" + assert {"field": "vendor", "before": "Acme", "after": None} in rec["detail"]["changes"] + + +def test_new_option_combo_is_variant_add_existing_untouched(): + catalog = _catalog_mug() + diff = compute_diff( + catalog, + _canon("Handle,Title,Option1 Name,Option1 Value,Variant Price", + "moon-mug,Moon Mug,Size,Large,25.00"), + ) + [rec] = diff.records + assert rec["kind"] == "update" + kinds = [v.get("kind") for v in rec["detail"]["variants"]] + assert "add" in kinds + + +def test_error_product_classifies_error(): + diff = compute_diff({}, _canon("Handle,Title,Variant Price", "mug,Mug,nope")) + [rec] = diff.records + assert rec["kind"] == "error" + assert rec["detail"]["errors"][0]["column"] == "Variant Price" + + +def test_fingerprint_stable_and_drift_sensitive(): + d1 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW)) + d2 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW)) + d3 = compute_diff(_catalog_mug(title="Renamed"), _canon(HEADER, MUG_ROW)) + assert d1.fingerprint == d2.fingerprint + assert d1.fingerprint != d3.fingerprint