docs(openspec): plan wave 6, brands and real sales

Planning only: proposal, delta specs, design and tasks. No code yet.

This is the last substantive in-scope box in docs/TBD-migrate-wave.md. Wave 1
removed the brand facet and the sales/comments sorts for want of a model; sales
are derivable from order_items and a brand model is a table plus a column, so
both come back for real.

Decisions recorded in the design:

- brands are reference data in their own table with a nullable products.brand_id
  and an ordered replace endpoint, mirroring categories and storefront content
- a "sale" is a unit on an order that reached payment; pending and cancelled
  orders do not count, so an abandoned checkout cannot inflate the figure
- sold_count is computed per read rather than stored, so it cannot drift from
  the orders that produced it
- the review UI is removed rather than relabelled: the mall attributes invented
  comments to named shoppers and shows a "good rate", which a migration that
  makes everything else real has no business keeping on screen. Reviews are
  recorded as a separate future capability

openspec validate --strict passes and the change is ready to apply.
This commit is contained in:
2026-09-17 17:17:52 +00:00
parent b44ba39e8c
commit 65cea42da6
7 changed files with 209 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-17
@@ -0,0 +1,62 @@
# Design
## Context
See `proposal.md` — Why. Wave 1 removed three things the catalog could not answer: the brand facet, and the sales and comments sorts (`search.vue` keeps only newest and price; `catalog.rs` accepts only `sort=price`).
Two of them now have real backing:
- `order_items` (`apps/api/migrations/0004_orders.sql`) records `sku_id` and `qty` per order line, and `skus.product_id` ties those to products — so units sold is derivable.
- `products` has no brand column, and `ProductUpsertBody` (`packages/shared/src/api.ts`) carries `category_id`, `slug`, `name`, `description` and `images` only, so merchants cannot set a brand today either.
The mall's current figures are hashes of the product id: `salesOf` and `commentCountOf` in `apps/mall/mock/data.ts`. `ProductCard.vue` renders both as "N sold · N reviews", and `pages/goods/[id].vue` renders a reviews tab with invented reviewers, ratings and replies.
## Goals / Non-Goals
**Goals:**
- Both remaining facets answer from real data: the brand filter from a brand table, the sales sort from order lines.
- Nothing in the product surfaces presents an invented figure as fact.
**Non-Goals:**
- No reviews capability. Writing, moderating and displaying reviews is a feature with its own lifecycle.
- No admin or shop-admin console UI for brands this wave — the API and the seed are enough to make them real.
- No change to the seckill, collective or integral pages, nor to coupons, favourites, addresses or the session cookie.
## Decisions
**1. Brands are reference data in their own table, with a nullable `products.brand_id`.**
Mirrors `categories`: a flat list with a position, administered centrally, referenced by products.
*Alternative:* a free-text brand on each product — no filterable vocabulary, and every shop would spell the same manufacturer differently.
**2. `PUT /api/admin/brands` replaces the ordered list, as storefront content does.**
Small ordered lists are edited whole; replacing reindexes positions and makes reordering, adding and deleting one operation.
**3. A "sale" is a unit on an order that reached payment.**
`sold_count` counts `order_items.qty` joined to orders whose status is `paid`, `fulfilling`, `shipped` or `completed`. Orders still `pending_payment` and `cancelled` orders do not count: a cart that was abandoned is not a sale, and a cancelled one was refunded by stock restore.
*Alternative:* counting every order line inflates the figure with abandoned checkouts.
**4. `sold_count` is computed per read, not stored.**
A grouped aggregate over `order_items` joins the existing product queries, so the number cannot drift from the orders that produced it.
*Alternative:* a `products.sold_count` column maintained when payment or cancellation happens — faster, but it makes correctness depend on every future order-path change, and this is cheap at MVP scale.
**5. The review UI goes, and reviews become declared future work.**
The product cards drop their review figure and the product detail page drops its reviews tab, summary and reply blocks. The mall attributes comments to named shoppers and shows a "good rate" percentage; a migration that turns everything else real has no business keeping invented opinions on screen. The after-sale tab stays, because it shows the shop's own `after_sale` copy.
*Alternative:* keep the reviews as sample content labelled as such — rejected: a demo label on a review list does not survive the page being shown to anyone else, and the data is attributed to customers.
**6. Merchants set a product's brand through the existing product upsert.**
`ProductUpsertBody` gains an optional `brand_id`, so `seed-demo.mjs` can assign brands to the demo products and merchants can do the same from their console.
*Alternative:* assign brands by direct SQL in the seed — bypasses the API the console uses, and leaves the field unsettable in the product form.
## Risks / Trade-offs
- [Removing the reviews tab is a visible feature loss] → deliberate, recorded in the proposal and the tracker, with a reviews capability named as the way back.
- [The sales aggregate runs on every listing query] → acceptable at this scale; a stored counter is the escape hatch if it ever matters, and decision 4 says why it is not the default.
- [A brand can be deleted while products reference it] → `products.brand_id` is `ON DELETE SET NULL`, so products survive with no brand rather than disappearing.
- [The brand facet reappears with an empty list until brands are seeded] → the seed assigns the demo brands, and the facet renders only when brands exist.
## Migration Plan
1. Ship the migration (brands table plus the nullable column), the brand endpoints and the `sold_count` aggregate; nothing in the mall reads them yet.
2. Seed brands and assign them to the demo products.
3. Flip the search facet, the sorts, the card figure and the detail page in one commit.
4. Rollback: revert those pages. The brand tables are additive, and the fixed-data adapter keeps serving `brandId` from `PRODUCT_BRAND`.
@@ -0,0 +1,33 @@
# Proposal
## Why
Wave 1 removed the brand facet and the sales/comments sorts because nothing backed them. Two of those three gaps now close for real: sales derive from `order_items`, and a brand model is a small table plus a product column.
## What Changes
- Add a `brands` table (bilingual name, slug) and `products.brand_id`, with a public `GET /api/brands`, a `brand_id` filter on `listProducts`, and the search page's brand facet restored.
- Manage brands with `PUT /api/admin/brands`, a transactional ordered replace like storefront content, and seed the demo products' brands.
- Add `sort=sales` to `listProducts`, computed from `order_items` over orders that reached payment, and carry a real `sold_count` on product payloads instead of the mall's hash-derived figure.
- Show that real sold count on product cards and the product detail page.
- **BREAKING** (display): remove the review UI — the card's review figure and the product detail page's reviews tab — because a reviews capability does not exist and the mall currently presents invented reviewers and ratings as fact.
- Keep the detail page's after-sale tab, which shows the shop's real copy.
## Capabilities
### New Capabilities
- `brand`: the product brand registry — a bilingual, admin-managed list that products reference and shoppers filter by.
### Modified Capabilities
- `catalog`: public listing filters by brand and can sort by real sales, and product payloads report a sold count.
- `frontend-mall`: the search page offers the brand facet and a sales sort again, and the product detail page no longer presents reviews.
## Impact
A migration and new route module; `packages/shared/src/{api,types}.ts`; `pages/search.vue`, `components/ui/ProductCard.vue`, `pages/goods/[id].vue`; `scripts/seed-demo.mjs`; the mall's fixed-data adapter. The contract change rebuilds all three frontends.
## Non-goals
No reviews capability: writing and moderating reviews is a feature, not a migration, and it is recorded as future work. No change to the seckill, collective or integral pages, nor to coupons, favourites, addresses or the cookie.
@@ -0,0 +1,29 @@
# Spec Delta
## Purpose
The product brand registry: a bilingual, admin-managed list of manufacturers that products reference, shoppers filter listings by, and the storefront shows on a product.
## ADDED Requirements
### Requirement: Public brand list
`GET /api/brands` SHALL return the brands without authentication, ordered by their stored position, each with an id, slug and bilingual name. Products SHALL carry a nullable `brand_id` referring to that list, and `GET /api/products` SHALL accept a `brand_id` filter that composes with the existing category, shop and keyword filters.
#### Scenario: filter a listing by brand
- **WHEN** a shopper filters the catalog by a brand
- **THEN** only that brand's published products are returned, and the filter combines with a category filter rather than replacing it
#### Scenario: a product without a brand
- **WHEN** a product has no brand assigned
- **THEN** it is still listed and its `brand_id` is null rather than pointing at an invented brand
### Requirement: Brand management
A platform admin SHALL replace the brand list with `PUT /api/admin/brands`, which validates each entry, applies as one transaction and reindexes positions from the submitted order; the write SHALL require the `platform_admin` role. Each entry SHALL carry a slug and non-empty `en` and `zh` names, and a rejected list SHALL leave the stored brands untouched.
#### Scenario: replace round-trips
- **WHEN** an admin replaces the brands and then reads them publicly
- **THEN** the public list matches, in the submitted order
#### Scenario: incomplete bilingual name is refused
- **WHEN** an admin submits a brand with only `en` text
- **THEN** the request fails and the stored brands are unchanged
@@ -0,0 +1,30 @@
# Spec Delta
## MODIFIED Requirements
### 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`
@@ -0,0 +1,18 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Product discovery pages
The mall SHALL provide `/search` with breadcrumb, category, brand and sort controls, a five-column desktop product grid, pagination and an empty state, listing products from the catalog API filtered by the selected category's subtree and brand. The sort control SHALL offer newest-first, price and sales. It SHALL provide `/goods/[id]` rendering product and SKU data from the catalog API with image gallery/zoom, bilingual name/subtitle, integer-minor-unit prices, attribute and SKU selection, stock-aware quantity, store card, and detail/after-sale tabs. Product cards and the product detail page SHALL show the product's real sold count, and the mall SHALL NOT present reviews, ratings or reviewer comments while no reviews capability exists.
#### Scenario: filter and inspect a product
- **WHEN** a shopper filters the search page by a parent category and a brand, then opens a product
- **THEN** products from that category's subtree matching the brand are listed, and selecting an in-stock SKU updates the displayed price, stock and cart target from the catalog API
#### Scenario: sort by sales
- **WHEN** a shopper sorts the search results by sales
- **THEN** the order follows the products' reported sold counts
#### Scenario: no invented reviews
- **WHEN** a shopper opens a product
- **THEN** the page shows the shop's own after-sale copy and no rating, review count or reviewer comment
@@ -0,0 +1,35 @@
# Tasks
## 1. Schema and seed
- [ ] 1.1 Add a migration creating `brands` (bilingual name, slug, position, active) and `products.brand_id` nullable with `ON DELETE SET NULL`; verify the column and table exist after `cargo run -p vmall-api`
- [ ] 1.2 Seed the six demo brands and assign them to the demo products from `scripts/seed-demo.mjs`; verify a re-run is idempotent and `GET /api/brands` returns them in order
## 2. Shared contract
- [ ] 2.1 Add `Brand` and `BrandInput` to `packages/shared/src/types.ts`, add the optional `brand_id` to the product payload and `ProductUpsertBody`, and add `brand_id` to `ProductListQuery` plus `"sales"` to its `sort`; verify all three frontends build
- [ ] 2.2 Add `listBrands()`, `admin.getBrands()` / `admin.replaceBrands(list)` to the `ApiClient` and `createApi`, and give the fixed-data adapter matching implementations so the rollback path still serves a brand list and a brand filter; register a `brands` domain in the per-domain switch
## 3. Catalog: brands
- [ ] 3.1 Add `GET /api/brands` (public, position-ordered) and `PUT /api/admin/brands` (admin, transactional replace, validating slug and non-empty `en`/`zh`); verify a rejected list changes nothing and a non-admin is refused
- [ ] 3.2 Add the `brand_id` filter to `listProducts`, composing with the category, shop and keyword filters, and return `brand_id` on the product payload; verify a brand plus category filter narrows correctly
- [ ] 3.3 Accept `brand_id` in the shop product upsert so a merchant, and the seed, can set it; verify a merchant can set and clear it on their own product only
## 4. Catalog: real sales
- [ ] 4.1 Compute `sold_count` per product from `order_items` joined to orders that reached payment (`paid`, `fulfilling`, `shipped`, `completed`), exposed on the product list and detail payloads; verify a product with no paid orders reports zero
- [ ] 4.2 Accept `sort=sales` with `order=asc|desc`, keeping the 400 for any other sort value; verify descending order matches the reported counts and that an unpaid order does not move a product
- [ ] 4.3 Extend `apps/api/tests/catalog.rs` with brand filtering, the sales order and the unpaid-order exclusion; verify `cargo test -p vmall-api` is green and repeatable
## 5. Mall surfaces
- [ ] 5.1 `pages/search.vue`: restore the brand facet from `listBrands()` and add the sales sort; verify the facet renders only when brands exist and that both filters compose
- [ ] 5.2 `components/ui/ProductCard.vue`: show the product's real `sold_count` and drop the review figure; verify no fabricated number remains
- [ ] 5.3 `pages/goods/[id].vue`: show the real sold count, and remove the reviews tab, its summary and reply blocks along with their mock imports; verify the page renders detail and after-sale tabs only
## 6. Verification
- [ ] 6.1 Run all three frontend builds and `cargo test -p vmall-api`; verify green. Treat the browser check as the real gate, since `nuxt build` does not typecheck (recorded in `docs/TBD-migrate-wave.md`)
- [ ] 6.2 With the backend seeded, verify in a browser: the search page filters by brand and sorts by sales, a product card shows a real sold count, and the product page has no reviews while keeping its after-sale copy
- [ ] 6.3 Verify the rollback: with every domain on fixed data and the backend stopped, the search facet and sorts still work from the fixed-data brand list