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