SLICE-8: Shopify dialect — detect/map Shopify export at the codec boundary + DOC-2/3 (SD-0002 §7.2) #32

Merged
ben.stull merged 11 commits from slice-8-shopify-dialect into main 2026-06-12 09:37:38 +00:00
4 changed files with 117 additions and 1 deletions
Showing only changes of commit 60a06de302 - Show all commits
+3 -1
View File
@@ -35,11 +35,13 @@ from .service import (
# DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv. # DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv.
SAMPLE_CSV_PATH = Path(__file__).parent / "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__ = [ __all__ = [
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired", "ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
"PreviewStale", "NothingToApply", "RunNotFound", "EmptyCatalog", "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", "import_validate", "get_draft", "get_draft_records", "discard_draft",
"confirm_draft", "list_runs", "get_run", "summary", "export_catalog", "confirm_draft", "list_runs", "get_run", "summary", "export_catalog",
"run_image_phase", "recover_incomplete_runs", "image_for_serving", "run_image_phase", "recover_incomplete_runs", "image_for_serving",
+95
View File
@@ -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`,
`Option13 Name` / `Option13 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.
+8
View File
@@ -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"'}, 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 # 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 # 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. # dist dir doesn't exist (Vite serves the frontend) and the mount is skipped.
+11
View File
@@ -95,6 +95,17 @@ def test_sample_csv_imports_clean(fresh_db_url):
assert body["summary"]["errors"] == 0 and body["summary"]["adds"] == 2 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): def test_export_returns_canonical_csv(fresh_db_url):
with _merchant_client(fresh_db_url) as client: with _merchant_client(fresh_db_url) as client:
draft = _upload(client).json() draft = _upload(client).json()