Files
wiggleverse-ecomm/backend/app/domains/products/diff.py
T

351 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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