diff --git a/backend/app/domains/products/__init__.py b/backend/app/domains/products/__init__.py new file mode 100644 index 0000000..8115618 --- /dev/null +++ b/backend/app/domains/products/__init__.py @@ -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", +] diff --git a/backend/app/domains/products/errors.py b/backend/app/domains/products/errors.py new file mode 100644 index 0000000..310cebe --- /dev/null +++ b/backend/app/domains/products/errors.py @@ -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.""" diff --git a/backend/app/domains/products/models.py b/backend/app/domains/products/models.py new file mode 100644 index 0000000..380f532 --- /dev/null +++ b/backend/app/domains/products/models.py @@ -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