diff --git a/backend/app/domains/products/__init__.py b/backend/app/domains/products/__init__.py index 5464853..0bea1f3 100644 --- a/backend/app/domains/products/__init__.py +++ b/backend/app/domains/products/__init__.py @@ -35,11 +35,13 @@ from .service import ( # DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv. SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv" +# DOC-2: the column reference the BFF serves at /api/products/columns.md. +COLUMNS_MD_PATH = Path(__file__).parent / "columns.md" __all__ = [ "ProductsError", "FileRejected", "DraftNotFound", "DraftExpired", "PreviewStale", "NothingToApply", "RunNotFound", "EmptyCatalog", - "MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH", + "MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH", "COLUMNS_MD_PATH", "import_validate", "get_draft", "get_draft_records", "discard_draft", "confirm_draft", "list_runs", "get_run", "summary", "export_catalog", "run_image_phase", "recover_incomplete_runs", "image_for_serving", diff --git a/backend/app/domains/products/columns.md b/backend/app/domains/products/columns.md new file mode 100644 index 0000000..e3cd2ba --- /dev/null +++ b/backend/app/domains/products/columns.md @@ -0,0 +1,95 @@ +# Product CSV — column reference + +This is the complete reference for the product CSV you import into and export from +your store (SD-0002 §6.5.1). The format is **canonical** — a clean superset of the +Shopify product CSV — and a **Shopify product CSV** imports directly too: we detect +which one you uploaded and map it for you (see *Shopify dialect* below). + +You don't pick a format. Upload your file; the preview tells you which format was +recognized and shows exactly what will change before anything is applied. + +## File shape + +- UTF-8 (a byte-order mark is tolerated), comma-delimited, RFC 4180 quoting. +- A header row is **required**. `Handle` and `Title` are the only always-required + columns. +- Up to 5,000 rows and 10 MB per file. Split larger catalogs and import in parts. + +## Row grammar + +Consecutive rows that share a `Handle` describe **one product**. The product's first +row carries the product-level fields (`Title` is required there). Each row may carry: + +- a **variant** — its `Option n Value`s plus `Variant *` fields; +- an **image** — `Image Src` with `Image Position` / `Image Alt Text`; +- or both. + +An image-only row (just `Handle` + `Image *`) is valid — that's how a product carries +more images than it has variants. + +## Updating: blank vs. absent + +- A column **absent from your file** is left untouched on existing products. +- A cell that is **present but empty** clears that field (resets it to its default). + +Every clear is shown explicitly in the preview, so this is deliberate, visible +behavior — never a silent surprise. + +## Canonical columns + +| Column | Level | Required | Notes | +| --- | --- | --- | --- | +| `Handle` | product | every row | Identity: lowercase letters, numbers, and dashes. | +| `Title` | product | first row of a product | | +| `Description` | product | — | HTML allowed; sanitized on import. | +| `Vendor` | product | — | Free text. | +| `Type` | product | — | `standalone` (default). `kit_virtual` / `kit_assembled` are reserved; any non-`standalone` value is a row error until kits ship. | +| `Google Product Category` | product | — | Taxonomy string. | +| `Tags` | product | — | Comma-separated within the cell. | +| `Status` | product | — | `draft` \| `active` \| `archived`; defaults to `active` on add. | +| `Published` | product | — | `TRUE` \| `FALSE`; defaults to `TRUE` on add. | +| `Option1 Name` … `Option3 Name` | product | with values | e.g. "Size". A value without its name is a row error. | +| `Option1 Value` … `Option3 Value` | variant | per variant | The variant's identity combination. | +| `Variant SKU` | variant | — | Indexed; not identity. | +| `Variant Barcode` | variant | — | | +| `Variant Price` | variant | — | Decimal; no currency symbol. | +| `Variant Cost` | variant | — | Decimal. | +| `Variant Weight` + `Variant Weight Unit` | variant | — | Decimal weight plus a unit string. | +| `Variant Volume` + `Variant Volume Unit` | variant | — | Decimal volume plus a unit string. | +| `Variant Tax ID 1` / `Variant Tax ID 2` | variant | — | Opaque references. | +| `Variant Inventory Tracker` | variant | — | | +| `Variant Inventory Qty` | variant | — | Integer ≥ 0. | +| `Variant Position` | variant | — | Display order; defaults to file order. | +| `Image Src` | image | — | URL (or a store-hosted URL on re-import). | +| `Image Position` | image | — | Integer order. | +| `Image Alt Text` | image | — | | +| `Variant Image` | variant | — | URL for this variant's specific image. | +| `Component 1 SKU` / `Component 1 Quantity` … `Component 10 …` | variant | — | Reserved for kits — a non-empty value is a row error today. | + +## Shopify dialect + +If you upload a **Shopify product CSV**, we recognize it by its header set and map it +to the canonical columns automatically. The preview labels it *"Shopify product +CSV — mapped"*. + +**Mapped — renamed** + +| Shopify column | Becomes | +| --- | --- | +| `Body (HTML)` | `Description` | +| `Product Category` | `Google Product Category` | +| `Cost per item` | `Variant Cost` | +| `Variant Grams` | `Variant Weight` (in grams) | + +**Mapped — same name:** `Handle`, `Title`, `Vendor`, `Tags`, `Published`, `Status`, +`Option1–3 Name` / `Option1–3 Value`, `Variant SKU`, `Variant Barcode`, +`Variant Price`, `Variant Inventory Tracker`, `Variant Inventory Qty`, +`Variant Image`, `Image Src`, `Image Position`, `Image Alt Text`. + +**Not imported — listed in the preview as a heads-up, never silently dropped:** the +Shopify free-text `Type` (canonical `Type` is structural, so we don't fold a category +string into it), `Variant Compare At Price`, `Variant Inventory Policy`, +`Variant Fulfillment Service`, `Variant Requires Shipping`, `Variant Taxable`, +`Variant Tax Code`, `Gift Card`, SEO fields, every `Google Shopping / …` field, and +market/region price columns. These have no canonical home yet; your other data still +imports. diff --git a/backend/app/main.py b/backend/app/main.py index 21aefb7..99f6583 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -450,6 +450,14 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None = headers={"content-disposition": 'attachment; filename="ecomm-products-sample.csv"'}, ) + @app.get("/api/products/columns.md") + def products_columns_md(): + """The DOC-2 column reference. Documentation, so no auth gate (§6.4).""" + return PlainTextResponse( + products.COLUMNS_MD_PATH.read_text(), + media_type="text/markdown; charset=utf-8", + ) + # Deployed topology (launch-app SPEC §2): nginx proxies everything here, so the # backend serves the built SPA. Mounted LAST so /healthz and /api/* win. In dev the # dist dir doesn't exist (Vite serves the frontend) and the mount is skipped. diff --git a/backend/tests/test_products_endpoints.py b/backend/tests/test_products_endpoints.py index dbab21a..66de184 100644 --- a/backend/tests/test_products_endpoints.py +++ b/backend/tests/test_products_endpoints.py @@ -95,6 +95,17 @@ def test_sample_csv_imports_clean(fresh_db_url): assert body["summary"]["errors"] == 0 and body["summary"]["adds"] == 2 +def test_columns_md_served(fresh_db_url): + """DOC-2 column reference is app-served, unauthenticated documentation (§6.4).""" + with TestClient(create_app(database_url=fresh_db_url)) as client: + resp = client.get("/api/products/columns.md") + assert resp.status_code == 200 + assert "text/markdown" in resp.headers["content-type"] + body = resp.text + assert "Body (HTML)" in body # Shopify dialect notes present + assert "`Handle`" in body # canonical columns present + + def test_export_returns_canonical_csv(fresh_db_url): with _merchant_client(fresh_db_url) as client: draft = _upload(client).json()