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,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`.