feat(products): diff engine — add/update/unchanged/error + INV-11 fingerprint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -31,8 +31,10 @@ from .models import (
|
|||||||
|
|
||||||
_HANDLE_RE = re.compile(r"^[a-z0-9-]+$")
|
_HANDLE_RE = re.compile(r"^[a-z0-9-]+$")
|
||||||
_STATUSES = {"draft", "active", "archived"}
|
_STATUSES = {"draft", "active", "archived"}
|
||||||
# Product columns handled as attributes (title, option_names), not via fields{}.
|
# Title is handled as an attribute, not via fields{}. Option names live in BOTH
|
||||||
_ATTRIBUTE_COLUMNS = {"Title", "Option1 Name", "Option2 Name", "Option3 Name"}
|
# .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).
|
# Sentinel: the cell failed normalization (the error is already recorded).
|
||||||
_INVALID = object()
|
_INVALID = object()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user