feat(mall): restore real brand and sales facets

Wave 6, the last substantive piece of the mock-API migration. Wave 1 removed the
brand facet and the sales/comments sorts for want of a model; sales turn out to
be derivable from order_items and a brand model is a table plus a column.

- a `brands` table with a nullable `products.brand_id` and an ordered admin
  replace, mirroring categories and storefront content; a public `GET /api/brands`
  and a `brand_id` filter on the catalog, which the search page's facet uses
- `sold_count` per product, computed from `order_items` joined to orders that
  reached payment, so an abandoned or cancelled checkout cannot count as a sale.
  It is computed per read rather than stored, so it cannot drift from the orders
  that produced it
- `sort=sales` alongside `sort=price`; anything else is still a 400
- merchants can set a product's brand through the existing product upsert
- the review UI is gone: the card's review figure and the product detail page's
  reviews tab, summary and replies. There is no reviews model, and the mall
  attributed invented comments to named shoppers and showed a "good rate". The
  now-unreferenced fabrication helpers went with it (`salesOf`, `commentCountOf`,
  `commentsFor`, `commentStats`, `salesRankFor`, `productDetail`, `storeDetail`)

Two bugs found by checking rather than trusting: the fixed-data `listProducts`
had silently ignored `brand_id`, `sort` and `order`, so the restored facet
rendered but filtered nothing until the rollback check caught it; and the seed's
brand lookup read back through the shared `r` variable the product loop
reassigns, working once and then throwing.

Verified: 29 backend tests green including a new brand-and-sales case; all three
frontends build; searching filters by brand (24 to 6) and sorts by sales with
counts matching the API; a product page offers detail and after-sale tabs only,
with a real sold count; the fixed-data rollback filters by brand too.

OpenSpec change: openspec/changes/replace-mock-api-wave-6
This commit is contained in:
2026-09-17 17:35:28 +00:00
parent 65cea42da6
commit ce3e8db5b1
20 changed files with 487 additions and 193 deletions
+36 -10
View File
@@ -222,24 +222,50 @@ const products = [
{ shop: "terra-grocery", slug: "terra-leather-tote", category: "fashion-bags", price: 21900, stock: 26, name: { en: "Terra Leather Tote", zh: "大地真皮托特包" }, description: { en: "Full-grain leather tote with laptop sleeve.", zh: "头层牛皮托特包,含电脑隔层。" }, img: "terra-tote" },
];
// 5b. brands (admin-managed reference data), then assign one per product
const BRANDS = [
{ slug: "aurora", name: { en: "Aurora", zh: "极光" } },
{ slug: "nordwind", name: { en: "Nordwind", zh: "北风" } },
{ slug: "hexon", name: { en: "Hexon", zh: "赫克森" } },
{ slug: "mikado", name: { en: "Mikado", zh: "御门" } },
{ slug: "solace", name: { en: "Solace", zh: "索莱斯" } },
{ slug: "terra", name: { en: "Terra", zh: "大地" } },
];
r = await call("PUT", "/admin/brands", { token: admin, body: BRANDS });
if (r.status !== 200) fail("replace brands", r);
// Captured, not read back through `r`: the product loop reassigns `r`.
const brandList = r.data;
const brandBy = (slug) => brandList.find((b) => b.slug === slug)?.id ?? null;
const SHOP_BRAND = {
"demo-store": "solace",
"aurora-digital": "aurora",
"nordwind-home": "nordwind",
"terra-grocery": "terra",
};
// A couple of deliberate exceptions so more than four brands are in use.
const PRODUCT_BRAND = { "mechanical-keyboard": "hexon", "terra-leather-tote": "mikado" };
console.log(`brands ready: ${r.data.length}`);
for (const p of products) {
const token = ownerTokens.get(p.shop);
r = await call("POST", "/shop/products", {
token,
body: {
slug: p.slug,
name: p.name,
description: p.description,
category_id: catBy(p.category),
images: [`https://picsum.photos/seed/${p.img}/600/600`],
},
});
const body = {
slug: p.slug,
name: p.name,
description: p.description,
category_id: catBy(p.category),
brand_id: brandBy(PRODUCT_BRAND[p.slug] ?? SHOP_BRAND[p.shop]),
images: [`https://picsum.photos/seed/${p.img}/600/600`],
};
r = await call("POST", "/shop/products", { token, body });
let id;
if (r.status === 201) id = r.data.id;
else if (r.status === 409) {
const list = await call("GET", "/shop/products?per_page=100", { token });
id = list.data.items.find((x) => x.slug === p.slug)?.id;
if (!id) fail(`lookup product ${p.slug}`, r);
// Re-apply the body so a re-run converges the brand assignment too.
r = await call("PUT", `/shop/products/${id}`, { token, body });
if (r.status !== 200) fail(`update product ${p.slug}`, r);
} else fail(`create product ${p.slug}`, r);
r = await call("POST", `/shop/products/${id}/skus`, {