feat(products): domain skeleton — errors, column registry, canonical row model
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
"""products domain — catalog + bulk CSV import/export (SD-0002 §6.2).
|
||||||
|
|
||||||
|
Owns the canonical row model, codec, validation, diff engine, and import
|
||||||
|
drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation
|
||||||
|
(INV-10). Imported via this package surface only.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .errors import (
|
||||||
|
DraftExpired,
|
||||||
|
DraftNotFound,
|
||||||
|
FileRejected,
|
||||||
|
NothingToApply,
|
||||||
|
PreviewStale,
|
||||||
|
ProductsError,
|
||||||
|
RunNotFound,
|
||||||
|
)
|
||||||
|
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
|
||||||
|
"PreviewStale", "NothingToApply", "RunNotFound",
|
||||||
|
"MAX_DATA_ROWS", "MAX_FILE_BYTES",
|
||||||
|
]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""products domain errors (SD-0002 §6.4 error envelope codes)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class ProductsError(Exception):
|
||||||
|
"""Base for products-domain errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class FileRejected(ProductsError):
|
||||||
|
"""PUC-5a: the whole file is unusable; no draft is created. `code` is the §6.4
|
||||||
|
error code (not_csv | missing_required_column | unknown_dialect | too_many_rows |
|
||||||
|
file_too_large)."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
class DraftNotFound(ProductsError):
|
||||||
|
"""No such draft for this storefront (or already discarded)."""
|
||||||
|
|
||||||
|
|
||||||
|
class DraftExpired(ProductsError):
|
||||||
|
"""The draft's validity window passed (§6.3 ~1 h)."""
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewStale(ProductsError):
|
||||||
|
"""INV-11: the catalog changed since validation — the previewed diff no longer holds."""
|
||||||
|
|
||||||
|
|
||||||
|
class NothingToApply(ProductsError):
|
||||||
|
"""PUC-10: no adds and no updates — confirming would be a no-op."""
|
||||||
|
|
||||||
|
|
||||||
|
class RunNotFound(ProductsError):
|
||||||
|
"""No such import run for this storefront."""
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Canonical row model + column registry — the one model every dialect maps to (INV-17)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
# §6.5.1 canonical columns, by level. Header detection, unknown-column warnings, and
|
||||||
|
# validation all read from this registry.
|
||||||
|
PRODUCT_COLUMNS: dict[str, str] = {
|
||||||
|
# column -> product field name
|
||||||
|
"Title": "title",
|
||||||
|
"Description": "description_html",
|
||||||
|
"Vendor": "vendor",
|
||||||
|
"Type": "product_type",
|
||||||
|
"Google Product Category": "google_product_category",
|
||||||
|
"Tags": "tags",
|
||||||
|
"Status": "status",
|
||||||
|
"Published": "published",
|
||||||
|
"Option1 Name": "option1_name",
|
||||||
|
"Option2 Name": "option2_name",
|
||||||
|
"Option3 Name": "option3_name",
|
||||||
|
}
|
||||||
|
VARIANT_COLUMNS: dict[str, str] = {
|
||||||
|
"Variant SKU": "sku",
|
||||||
|
"Variant Barcode": "barcode",
|
||||||
|
"Variant Price": "price",
|
||||||
|
"Variant Cost": "cost",
|
||||||
|
"Variant Weight": "weight",
|
||||||
|
"Variant Weight Unit": "weight_unit",
|
||||||
|
"Variant Volume": "volume",
|
||||||
|
"Variant Volume Unit": "volume_unit",
|
||||||
|
"Variant Tax ID 1": "tax_id_1",
|
||||||
|
"Variant Tax ID 2": "tax_id_2",
|
||||||
|
"Variant Inventory Tracker": "inventory_tracker",
|
||||||
|
"Variant Inventory Qty": "inventory_qty",
|
||||||
|
"Variant Position": "position",
|
||||||
|
"Variant Image": "variant_image",
|
||||||
|
}
|
||||||
|
OPTION_VALUE_COLUMNS = ("Option1 Value", "Option2 Value", "Option3 Value")
|
||||||
|
IMAGE_COLUMNS = ("Image Src", "Image Position", "Image Alt Text")
|
||||||
|
COMPONENT_COLUMNS = tuple(
|
||||||
|
f"Component {i} {kind}" for i in range(1, 11) for kind in ("SKU", "Quantity")
|
||||||
|
)
|
||||||
|
KNOWN_COLUMNS = (
|
||||||
|
{"Handle"}
|
||||||
|
| set(PRODUCT_COLUMNS)
|
||||||
|
| set(VARIANT_COLUMNS)
|
||||||
|
| set(OPTION_VALUE_COLUMNS)
|
||||||
|
| set(IMAGE_COLUMNS)
|
||||||
|
| set(COMPONENT_COLUMNS)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clearing a field (present-but-empty cell, §6.5.1) resets it to its default.
|
||||||
|
CLEAR_DEFAULTS: dict[str, object] = {
|
||||||
|
"status": "active",
|
||||||
|
"published": True,
|
||||||
|
"product_type": "standalone",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
MAX_DATA_ROWS = 5_000 # INV-18
|
||||||
|
MAX_FILE_BYTES = 10 * 1024 * 1024 # INV-18
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Row:
|
||||||
|
"""One CSV data row: 1-based file line number + the cells of known columns
|
||||||
|
present in the header (column name -> raw string, possibly empty)."""
|
||||||
|
|
||||||
|
line_number: int
|
||||||
|
cells: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedFile:
|
||||||
|
dialect: str
|
||||||
|
header: list[str]
|
||||||
|
unknown_columns: list[str]
|
||||||
|
rows: list[Row]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RowError:
|
||||||
|
line_number: int
|
||||||
|
column: str | None
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def as_json(self) -> dict:
|
||||||
|
return {"line": self.line_number, "column": self.column, "message": self.message}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CanonicalVariant:
|
||||||
|
line_number: int
|
||||||
|
options: tuple[str | None, str | None, str | None]
|
||||||
|
# field name -> normalized value; present only for columns in the file.
|
||||||
|
# value None == clear (reset to default/NULL).
|
||||||
|
fields: dict[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CanonicalImage:
|
||||||
|
line_number: int
|
||||||
|
source_url: str
|
||||||
|
position: int
|
||||||
|
alt_text: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CanonicalProduct:
|
||||||
|
first_line: int
|
||||||
|
handle: str
|
||||||
|
title: str # "" when missing (the block then carries an error)
|
||||||
|
option_names: tuple[str | None, str | None, str | None] = (None, None, None)
|
||||||
|
fields: dict[str, object] = field(default_factory=dict) # product-level, same semantics
|
||||||
|
variants: list[CanonicalVariant] = field(default_factory=list)
|
||||||
|
images: list[CanonicalImage] = field(default_factory=list)
|
||||||
|
errors: list[RowError] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def valid(self) -> bool:
|
||||||
|
return not self.errors
|
||||||
Reference in New Issue
Block a user