SLICE-8: Shopify dialect — detect/map Shopify export at the codec boundary + DOC-2/3 (SD-0002 §7.2) #32
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user