feat(products): confirm/apply in one transaction, runs, summary — INV-10/11/14 + TEL-2/6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:02:27 -07:00
parent fcbf1393f5
commit 0c7865e9e1
5 changed files with 439 additions and 39 deletions
+127 -37
View File
@@ -1,9 +1,12 @@
"""Diff engine — catalog × canonical products → preview records (SD-0002 §6.5.2).
"""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, 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).
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{}
@@ -16,7 +19,7 @@ from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from dataclasses import dataclass, field
from decimal import Decimal
from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant
@@ -54,11 +57,49 @@ class CatalogProduct:
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
# 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
@@ -69,16 +110,20 @@ _SUMMARY_KEY = {"add": "adds", "update": "updates", "unchanged": "unchanged", "e
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)
if product.errors:
kind, detail = "error", {"errors": [e.as_json() for e in 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:
kind, detail = "add", _add_detail(product)
product_plan = _add_plan(product)
detail = _add_detail(product_plan)
else:
detail = _update_detail(product, current)
kind = "update" if detail else "unchanged"
product_plan, detail = _update_plan(product, current)
kind = product_plan.kind
plan.append(product_plan)
records.append(
{
"handle": product.handle,
@@ -92,86 +137,125 @@ def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalPro
fingerprint = hashlib.sha256(
json.dumps(records, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
return DiffResult(records=records, summary=summary, fingerprint=fingerprint)
return DiffResult(records=records, summary=summary, fingerprint=fingerprint, plan=plan)
def _add_detail(product: CanonicalProduct) -> dict:
def _add_plan(product: CanonicalProduct) -> ProductPlan:
return ProductPlan(
kind="add",
canonical=product,
catalog=None,
variant_plans=[VariantPlan(kind="add", canonical=v, catalog_id=None) for v in product.variants],
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(product)
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(product.option_names),
"option_names": list(plan.canonical.option_names),
"variants": [
{"options": list(v.options), "set": _resolved_variant_fields(v)}
for v in product.variants
{"options": list(vp.canonical.options), "set": _resolved_variant_fields(vp.canonical)}
for vp in plan.variant_plans
],
"images": [
{"src": i.source_url, "position": i.position, "alt_text": i.alt_text}
for i in product.images
{"src": ip.source_url, "position": ip.position, "alt_text": ip.alt_text}
for ip in plan.image_plans
],
}
def _update_detail(product: CanonicalProduct, current: CatalogProduct) -> dict:
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():
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 variant in product.variants:
match = by_options.get(variant.options)
if match is None:
variant_plans.append(VariantPlan(kind="add", canonical=variant, catalog_id=None))
variant_entries.append(
{"options": list(variant.options), "kind": "add", "set": _resolved_variant_fields(variant)}
)
continue
variant_changes = []
for f, resolved in _resolved_fields(variant.fields).items():
variant_changes: dict[str, object] = {}
variant_change_entries: list[dict] = []
for f, resolved in resolved_fields(variant.fields).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.append(_change(f, before, resolved))
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, changes=variant_changes)
)
variant_entries.append(
{"options": list(variant.options), "kind": "update", "changes": variant_changes}
{"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 = [
_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
]
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_entries.append({"src": image.source_url, "kind": "update", "changes": 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:
@@ -180,22 +264,28 @@ def _update_detail(product: CanonicalProduct, current: CatalogProduct) -> dict:
detail["variants"] = variant_entries
if image_entries:
detail["images"] = image_entries
return detail
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]:
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]:
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
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()}
return {f: _json_safe(v) for f, v in resolved_fields(variant.fields).items()}
def _change(field_name: str, before: object, after: object) -> dict: