The merge creates the brand capability (two requirements) and extends the catalog browse requirement with the brand filter, the sales sort and a real sold count. openspec validate --all --strict stays green at 14 specs. With every box now checked, docs/TBD-migrate-wave.md is deleted, as its own instructions require. Its two durable notes move to the README instead of disappearing with it: - `nuxt build` does not typecheck, so a green build is not the type gate the verification section claimed. AGENTS.md told me to treat it as one, which is why waves 1-5 over-reported "builds pass" as evidence. - the list of what stays deliberately mock: addresses, coupons, favourites, account stats, the seckill/collective/integral pages, reviews, and the fixed-data adapter itself, which the adapter spec requires for rollback. The README's verification block also gains the real test count (29, not 16).
68 lines
3.9 KiB
Markdown
68 lines
3.9 KiB
Markdown
# catalog Specification
|
|
|
|
## Purpose
|
|
The public product and category catalogue: localized content, publish lifecycle, shop isolation, SKU pricing and public browse.
|
|
|
|
## Requirements
|
|
|
|
### Requirement: Localized product content
|
|
Product and category names/descriptions SHALL be stored as JSONB maps keyed by locale (`en`, `zh`). The API MUST return the full map; clients pick the display locale.
|
|
|
|
#### Scenario: bilingual round-trip
|
|
- **WHEN** a shop owner creates a product with name `{"en": "Mug", "zh": "马克杯"}`
|
|
- **THEN** both public detail and shop-admin GET return the identical map
|
|
|
|
### Requirement: Publish lifecycle
|
|
Products SHALL have status `draft | published | unpublished`. Only `published` products appear in public list/detail.
|
|
|
|
#### Scenario: publish then unpublish
|
|
- **WHEN** a product is published
|
|
- **THEN** it appears in `GET /api/products`
|
|
- **WHEN** it is unpublished
|
|
- **THEN** public detail returns 404 and it disappears from listings
|
|
|
|
#### Scenario: publish requires sellable SKU
|
|
- **WHEN** publishing a product with no active SKU having price > 0
|
|
- **THEN** the API returns 400
|
|
|
|
### Requirement: Shop isolation
|
|
Shop-role users SHALL only see and mutate their own shop's products under `/api/shop/products`.
|
|
|
|
#### Scenario: cross-shop access denied
|
|
- **WHEN** shop owner A requests `/api/shop/products/{id}` of shop B
|
|
- **THEN** the API returns 404
|
|
|
|
### Requirement: SKU pricing
|
|
Each SKU SHALL carry `price_minor` (integer minor units) and an ISO `currency` code; stock is a non-negative integer.
|
|
|
|
#### Scenario: negative stock rejected
|
|
- **WHEN** upserting a SKU with stock < 0
|
|
- **THEN** the API returns 400
|
|
|
|
### Requirement: Public product browse
|
|
Public `GET /api/products` SHALL return only `published` products whose shop is active, and SHALL remain readable without authentication. When `category_id` is supplied, the filter SHALL match that category **and every category beneath it**, so requesting a parent category returns products assigned to its child and grandchild categories. When `brand_id` is supplied the filter SHALL match that brand and compose with the other filters. The listing SHALL accept an optional `sort` of `price` or `sales`: `price` orders by each product's lowest active SKU price, and `sales` orders by units sold across orders that reached payment, which SHALL also be reported per product as `sold_count`. Any other `sort` value SHALL be rejected with a 400 `ApiError` rather than silently ignored. An unsorted listing SHALL order newest first. Paging SHALL keep returning `page` and `per_page` alongside the filtered `total`.
|
|
|
|
#### Scenario: parent category includes descendant products
|
|
- **WHEN** a shopper requests products for a category that has child categories holding published products
|
|
- **THEN** the response contains the products assigned to those descendant categories, not only those assigned directly to the requested category
|
|
|
|
#### Scenario: sort by lowest active SKU price
|
|
- **WHEN** a shopper requests the product list with `sort=price` and `order=asc`
|
|
- **THEN** products come back ordered by their lowest active SKU price ascending
|
|
|
|
#### Scenario: sort by units sold
|
|
- **WHEN** a shopper requests the product list with `sort=sales` and `order=desc`
|
|
- **THEN** products come back ordered by their `sold_count` descending, and a product with no paid orders reports zero rather than being omitted
|
|
|
|
#### Scenario: filter by brand
|
|
- **WHEN** a shopper requests products with a `brand_id` alongside a `category_id`
|
|
- **THEN** only products matching both filters are returned, and `total` reflects the combined filter
|
|
|
|
#### Scenario: unsupported sort is rejected
|
|
- **WHEN** a client requests a `sort` value that is neither `price` nor `sales`
|
|
- **THEN** the API responds 400 with an `ApiError` body instead of ignoring the parameter
|
|
|
|
#### Scenario: unpublished products never appear
|
|
- **WHEN** any public listing or filter is applied
|
|
- **THEN** products that are not `published`, or whose shop is not active, are absent from both `items` and `total`
|