984dc98dee
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
351 lines
14 KiB
Python
351 lines
14 KiB
Python
"""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
|