"""Diff engine — catalog × canonical products → apply plan + preview records (SD-0002 §6.5.2). Classifies each canonical product against the storefront's current catalog as add / update / unchanged / error. One walk produces two views of the same computation: a typed apply *plan* carrying resolved native values (Decimal etc.) for the confirm transaction, and JSON-ready preview records derived from that walk (stored as draft JSONB, served verbatim to the SPA) — so what confirm applies is exactly what preview showed (INV-11). A summary and a deterministic fingerprint over the records detect catalog drift between preview and confirm. 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 — except a variant's position, whose default is the variant's 1-based file order within its product ("defaults to file order"), resolved here at diff time. Catalog variants/images absent from the file are likewise untouched (INV-10); file variants match catalog variants by their option-value combination (INV-13). """ from __future__ import annotations import hashlib import json from dataclasses import dataclass, field, replace from decimal import Decimal from . import hosted from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant, RowError @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 status: str = "pending" @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] # --------------------------------------------------------------------------- # Apply plan — the typed twin of the preview records. confirm_draft executes # these; the values are resolved natives (Decimal, bool, list), never the # json-safe strings the records carry for display. # --------------------------------------------------------------------------- @dataclass class VariantPlan: kind: str # "add" | "update" canonical: CanonicalVariant catalog_id: int | None # None for add # 1-based index within the product's file variants — the position default. file_order: int = 0 # field -> resolved after-value (update only); adds resolve from canonical.fields changes: dict[str, object] = field(default_factory=dict) @dataclass class ImagePlan: kind: str # "add" | "update" source_url: str position: int alt_text: str | None image_id: int | None # None for add changes: dict[str, object] = field(default_factory=dict) # subset of {"position","alt_text"} @dataclass class ProductPlan: kind: str # "add" | "update" | "unchanged" | "error" canonical: CanonicalProduct catalog: CatalogProduct | None # field -> resolved after-value (update only; may include title/option*_name) product_changes: dict[str, object] = field(default_factory=dict) variant_plans: list[VariantPlan] = field(default_factory=list) image_plans: list[ImagePlan] = field(default_factory=list) @dataclass(frozen=True) class DiffResult: records: list[dict] summary: dict fingerprint: str plan: list[ProductPlan] # 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] = [] plan: list[ProductPlan] = [] summary = {"adds": 0, "updates": 0, "unchanged": 0, "errors": 0} for product in products: current = catalog.get(product.handle) _resolve_hosted_images(product, current) if product.errors: product_plan = ProductPlan(kind="error", canonical=product, catalog=current) detail: dict = {"errors": [e.as_json() for e in product.errors]} elif current is None: product_plan = _add_plan(product) detail = _add_detail(product_plan) else: product_plan, detail = _update_plan(product, current) kind = product_plan.kind plan.append(product_plan) 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=(",", ":")).encode() ).hexdigest() return DiffResult(records=records, summary=summary, fingerprint=fingerprint, plan=plan) def _resolve_hosted_images(product: CanonicalProduct, current: CatalogProduct | None) -> None: """Recognize re-imported hosted image URLs (INV-12). A hosted URL is resolved to this product's existing image with that id — re-keyed onto that image's source_url so the by_src match treats it as the existing image (never an add, never a fetch). A hosted URL whose id is not one of this product's images (cross-storefront, deleted, or a brand-new product) is a row error.""" current_by_id = {i.id: i for i in current.images} if current else {} resolved: list = [] for image in product.images: hosted_id = hosted.parse_image_id(image.source_url) if hosted_id is None: resolved.append(image) continue match = current_by_id.get(hosted_id) if match is None: product.errors.append( RowError(image.line_number, "Image Src", f"image '{image.source_url}' refers to an unknown hosted id") ) resolved.append(image) continue resolved.append(replace(image, source_url=match.source_url)) product.images = resolved def _add_plan(product: CanonicalProduct) -> ProductPlan: return ProductPlan( kind="add", canonical=product, catalog=None, variant_plans=[ VariantPlan(kind="add", canonical=v, catalog_id=None, file_order=order) for order, v in enumerate(product.variants, start=1) ], image_plans=[ ImagePlan(kind="add", source_url=i.source_url, position=i.position, alt_text=i.alt_text, image_id=None) for i in product.images ], ) def _add_detail(plan: ProductPlan) -> 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(plan.canonical) 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(plan.canonical.option_names), "variants": [ {"options": list(vp.canonical.options), "set": _resolved_variant_fields(vp.canonical, vp.file_order)} for vp in plan.variant_plans ], "images": [ {"src": ip.source_url, "position": ip.position, "alt_text": ip.alt_text} for ip in plan.image_plans ], } def _update_plan(product: CanonicalProduct, current: CatalogProduct) -> tuple[ProductPlan, dict]: """One walk, two outputs: the resolved-value plan entries and the json-safe record detail entries are appended side by side, so they can never diverge.""" product_changes: dict[str, object] = {} 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: product_changes["title"] = product.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: product_changes[field_name] = resolved 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: product_changes[f"option{slot}_name"] = after changes.append(_change(f"option{slot}_name", before, after)) variant_plans: list[VariantPlan] = [] variant_entries: list[dict] = [] by_options = {v.options: v for v in current.variants} for file_order, variant in enumerate(product.variants, start=1): match = by_options.get(variant.options) if match is None: variant_plans.append( VariantPlan(kind="add", canonical=variant, catalog_id=None, file_order=file_order) ) variant_entries.append( {"options": list(variant.options), "kind": "add", "set": _resolved_variant_fields(variant, file_order)} ) continue variant_changes: dict[str, object] = {} variant_change_entries: list[dict] = [] for f, resolved in resolved_variant_fields(variant, file_order).items(): # position lives on the catalog variant as an attribute, not in # fields{} — compare it explicitly (as images do for theirs). before = match.position if f == "position" else match.fields.get(f) if resolved != before: variant_changes[f] = resolved variant_change_entries.append(_change(f, before, resolved)) if variant_changes: variant_plans.append( VariantPlan(kind="update", canonical=variant, catalog_id=match.id, file_order=file_order, changes=variant_changes) ) variant_entries.append( {"options": list(variant.options), "kind": "update", "changes": variant_change_entries} ) image_plans: list[ImagePlan] = [] 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_plans.append( ImagePlan(kind="add", source_url=image.source_url, position=image.position, alt_text=image.alt_text, image_id=None) ) image_entries.append( {"src": image.source_url, "kind": "add", "position": image.position, "alt_text": image.alt_text} ) continue image_changes: dict[str, object] = {} image_change_entries: list[dict] = [] for f, before, after in ( ("position", match.position, image.position), ("alt_text", match.alt_text, image.alt_text), ): if after != before: image_changes[f] = after image_change_entries.append(_change(f, before, after)) if image_changes: image_plans.append( ImagePlan(kind="update", source_url=image.source_url, position=image.position, alt_text=image.alt_text, image_id=match.id, changes=image_changes) ) image_entries.append( {"src": image.source_url, "kind": "update", "changes": image_change_entries} ) detail: dict = {} if changes: detail["changes"] = changes if variant_entries: detail["variants"] = variant_entries if image_entries: detail["images"] = image_entries kind = "update" if detail else "unchanged" return ( ProductPlan(kind=kind, canonical=product, catalog=current, product_changes=product_changes, variant_plans=variant_plans, image_plans=image_plans), 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]: """Resolved product-level fields, minus the option-name presence markers.""" 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, file_order: int) -> dict[str, object]: """File-present variant fields with clears resolved. A cleared position has no CLEAR_DEFAULTS entry — it resets to the variant's 1-based file order within its product ("defaults to file order"), never to NULL.""" resolved = resolved_fields(variant.fields) if "position" in resolved and resolved["position"] is None: resolved["position"] = file_order return resolved def _resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]: return {f: _json_safe(v) for f, v in resolved_variant_fields(variant, file_order).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