diff --git a/apps/api/migrations/0008_brands.sql b/apps/api/migrations/0008_brands.sql new file mode 100644 index 0000000..757671b --- /dev/null +++ b/apps/api/migrations/0008_brands.sql @@ -0,0 +1,16 @@ +-- Product brands: reference data like categories, administered centrally and +-- referenced by products. A brand can be retired without touching its products. + +CREATE TABLE brands ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name JSONB NOT NULL, + slug TEXT NOT NULL UNIQUE, + position INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE products + ADD COLUMN brand_id UUID REFERENCES brands (id) ON DELETE SET NULL; + +CREATE INDEX products_brand_idx ON products (brand_id); diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 7aa90ea..d9ca1fb 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -67,11 +67,21 @@ pub struct Category { pub position: i32, } +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct Brand { + pub id: Uuid, + pub name: serde_json::Value, + pub slug: String, + pub position: i32, + pub active: bool, +} + #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct Product { pub id: Uuid, pub shop_id: Uuid, pub category_id: Option, + pub brand_id: Option, pub slug: String, pub name: serde_json::Value, pub description: serde_json::Value, diff --git a/apps/api/src/routes/brands.rs b/apps/api/src/routes/brands.rs new file mode 100644 index 0000000..f3270e1 --- /dev/null +++ b/apps/api/src/routes/brands.rs @@ -0,0 +1,101 @@ +use axum::{ + extract::State, + routing::{get, put}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::Value; + +use crate::auth::AuthUser; +use crate::error::{ApiError, ApiResult}; +use crate::models::{Brand, UserRole}; +use crate::state::AppState; + +pub fn router(_state: AppState) -> Router { + Router::new().route("/brands", get(list_brands)) +} + +pub fn admin_router(_state: AppState) -> Router { + Router::new().route("/admin/brands", put(replace_brands)) +} + +async fn list_brands(State(state): State) -> ApiResult>> { + let brands = sqlx::query_as::<_, Brand>( + "SELECT * FROM brands WHERE active = TRUE ORDER BY position, created_at", + ) + .fetch_all(&state.db) + .await?; + Ok(Json(brands)) +} + +#[derive(Deserialize)] +struct BrandInput { + slug: String, + name: Value, + #[serde(default = "default_active")] + active: bool, +} + +fn default_active() -> bool { + true +} + +/// Replaces the whole ordered list, as storefront content does: small lists are +/// edited whole, and positions are reindexed from the submitted order. +async fn replace_brands( + State(state): State, + auth: AuthUser, + Json(body): Json>, +) -> ApiResult>> { + auth.require(&[UserRole::PlatformAdmin])?; + + for brand in &body { + if brand.slug.trim().is_empty() + || !brand + .slug + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Err(ApiError::BadRequest(format!( + "invalid brand slug: {}", + brand.slug + ))); + } + let ok = ["en", "zh"].iter().all(|code| { + brand + .name + .get(code) + .and_then(Value::as_str) + .is_some_and(|s| !s.trim().is_empty()) + }); + if !ok { + return Err(ApiError::BadRequest(format!( + "brand {} needs non-empty en and zh names", + brand.slug + ))); + } + } + + let mut tx = state.db.begin().await?; + sqlx::query("DELETE FROM brands").execute(&mut *tx).await?; + for (i, brand) in body.iter().enumerate() { + sqlx::query( + "INSERT INTO brands (name, slug, position, active) VALUES ($1, $2, $3, $4)", + ) + .bind(&brand.name) + .bind(brand.slug.trim()) + .bind(i as i32) + .bind(brand.active) + .execute(&mut *tx) + .await + .map_err(|e| match e { + sqlx::Error::Database(d) if d.is_unique_violation() => { + ApiError::BadRequest(format!("duplicate brand slug: {}", brand.slug)) + } + other => ApiError::from(other), + })?; + } + tx.commit().await?; + + list_brands(State(state)).await +} diff --git a/apps/api/src/routes/catalog.rs b/apps/api/src/routes/catalog.rs index 6b309ca..9e39136 100644 --- a/apps/api/src/routes/catalog.rs +++ b/apps/api/src/routes/catalog.rs @@ -16,6 +16,27 @@ pub struct ProductWithSkus { #[serde(flatten)] pub product: Product, pub skus: Vec, + /// Units sold across orders that reached payment. Zero when nothing sold. + pub sold_count: i64, +} + +/// Orders that count as a sale: an abandoned or cancelled checkout does not. +const PAID_STATUSES: &str = + "('paid', 'fulfilling', 'shipped', 'completed')"; + +/// Units sold for one product, as a correlated subquery so it can also drive +/// the sales sort without a second round trip. +const SOLD_UNITS: &str = "(SELECT COALESCE(SUM(oi.qty), 0) + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN skus sk ON sk.id = oi.sku_id + WHERE sk.product_id = p.id + AND o.status IN ('paid', 'fulfilling', 'shipped', 'completed'))"; + +#[derive(sqlx::FromRow)] +struct SoldRow { + product_id: Uuid, + sold: i64, } pub async fn attach_skus( @@ -41,10 +62,31 @@ pub async fn attach_skus( .fetch_all(db) .await? }; + let sold: Vec = if ids.is_empty() { + Vec::new() + } else { + sqlx::query_as::<_, SoldRow>(&format!( + "SELECT sk.product_id, COALESCE(SUM(oi.qty), 0)::bigint AS sold + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + JOIN skus sk ON sk.id = oi.sku_id + WHERE sk.product_id = ANY($1) + AND o.status IN {PAID_STATUSES} + GROUP BY sk.product_id" + )) + .bind(&ids) + .fetch_all(db) + .await? + }; Ok(products .into_iter() .map(|p| ProductWithSkus { skus: skus.iter().filter(|s| s.product_id == p.id).cloned().collect(), + sold_count: sold + .iter() + .find(|row| row.product_id == p.id) + .map(|row| row.sold) + .unwrap_or(0), product: p, }) .collect()) @@ -62,6 +104,7 @@ struct ListQuery { page: Option, per_page: Option, category_id: Option, + brand_id: Option, shop_id: Option, q: Option, sort: Option, @@ -72,15 +115,18 @@ struct ListQuery { enum SortBy { Newest, Price, + Sales, } impl ListQuery { - /// Only `price` is a supported sort; anything else is a client error rather - /// than being silently ignored. An absent `sort` keeps newest-first. + /// Only `price` and `sales` are supported sorts; anything else is a client + /// error rather than being silently ignored. An absent `sort` keeps + /// newest-first. fn sort_by(&self) -> ApiResult { match self.sort.as_deref() { None => Ok(SortBy::Newest), Some("price") => Ok(SortBy::Price), + Some("sales") => Ok(SortBy::Sales), Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))), } } @@ -125,11 +171,13 @@ async fn list_products( WHERE p.status = 'published' AND s.status = 'active' AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree)) AND ($2::uuid IS NULL OR p.shop_id = $2) - AND ($3::text IS NULL OR p.name::text ILIKE $3)" + AND ($3::text IS NULL OR p.name::text ILIKE $3) + AND ($4::uuid IS NULL OR p.brand_id = $4)" )) .bind(q.category_id) .bind(q.shop_id) .bind(&pattern) + .bind(q.brand_id) .fetch_one(&state.db) .await?; @@ -138,6 +186,8 @@ async fn list_products( (SortBy::Newest, _) => "p.created_at DESC".to_string(), (SortBy::Price, true) => format!("{MIN_PRICE} ASC NULLS LAST, p.created_at DESC"), (SortBy::Price, false) => format!("{MIN_PRICE} DESC NULLS LAST, p.created_at DESC"), + (SortBy::Sales, true) => format!("{SOLD_UNITS} ASC, p.created_at DESC"), + (SortBy::Sales, false) => format!("{SOLD_UNITS} DESC, p.created_at DESC"), }; let products = sqlx::query_as::<_, Product>(&format!( "{SUBTREE_CTE} @@ -146,12 +196,14 @@ async fn list_products( AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree)) AND ($2::uuid IS NULL OR p.shop_id = $2) AND ($3::text IS NULL OR p.name::text ILIKE $3) + AND ($4::uuid IS NULL OR p.brand_id = $4) ORDER BY {order_clause} - LIMIT $4 OFFSET $5" + LIMIT $5 OFFSET $6" )) .bind(q.category_id) .bind(q.shop_id) .bind(&pattern) + .bind(q.brand_id) .bind(per_page) .bind((page - 1) * per_page) .fetch_all(&state.db) diff --git a/apps/api/src/routes/mod.rs b/apps/api/src/routes/mod.rs index 99b6dbe..72c0167 100644 --- a/apps/api/src/routes/mod.rs +++ b/apps/api/src/routes/mod.rs @@ -1,5 +1,6 @@ pub mod admin; pub mod auth; +pub mod brands; pub mod cart; pub mod catalog; pub mod content; @@ -23,6 +24,8 @@ pub fn api_router(state: AppState) -> Router { .merge(auth::router(state.clone())) .merge(currency::router(state.clone())) .merge(catalog::router(state.clone())) + .merge(brands::router(state.clone())) + .merge(brands::admin_router(state.clone())) .merge(content::router(state.clone())) .merge(content::admin_router(state.clone())) .merge(cart::router(state.clone())) diff --git a/apps/api/src/routes/shop_catalog.rs b/apps/api/src/routes/shop_catalog.rs index d9947cc..30e9c1f 100644 --- a/apps/api/src/routes/shop_catalog.rs +++ b/apps/api/src/routes/shop_catalog.rs @@ -100,6 +100,7 @@ async fn get_product( #[derive(Deserialize)] pub struct ProductBody { category_id: Option, + brand_id: Option, slug: String, name: serde_json::Value, description: Option, @@ -132,11 +133,12 @@ async fn create_product( let shop_id = require_shop(&auth)?; validate_product_body(&body)?; let product = sqlx::query_as::<_, Product>( - "INSERT INTO products (shop_id, category_id, slug, name, description, images) - VALUES ($1, $2, $3, $4, $5, $6) RETURNING *", + "INSERT INTO products (shop_id, category_id, brand_id, slug, name, description, images) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", ) .bind(shop_id) .bind(body.category_id) + .bind(body.brand_id) .bind(body.slug.trim()) .bind(&body.name) .bind(body.description.unwrap_or_else(|| serde_json::json!({}))) @@ -164,11 +166,13 @@ async fn update_product( validate_product_body(&body)?; let product = sqlx::query_as::<_, Product>( "UPDATE products - SET category_id = $2, slug = $3, name = $4, description = $5, images = $6, updated_at = now() + SET category_id = $2, brand_id = $3, slug = $4, name = $5, description = $6, + images = $7, updated_at = now() WHERE id = $1 RETURNING *", ) .bind(id) .bind(body.category_id) + .bind(body.brand_id) .bind(body.slug.trim()) .bind(&body.name) .bind(body.description.unwrap_or_else(|| serde_json::json!({}))) diff --git a/apps/api/tests/catalog.rs b/apps/api/tests/catalog.rs index 10f9284..3ee16bb 100644 --- a/apps/api/tests/catalog.rs +++ b/apps/api/tests/catalog.rs @@ -1,8 +1,9 @@ mod common; use common::{ - category_id_by_slug, client, create_product_with_sku, create_product_with_sku_in_category, - create_shop, login_admin, make_shop_owner, publish_product, register_customer, spawn_app, + add_to_cart, category_id_by_slug, checkout, client, create_product_full, create_product_with_sku, + create_product_with_sku_in_category, create_shop, login_admin, make_shop_owner, pay, + publish_product, register_customer, spawn_app, }; use serial_test::serial; @@ -274,6 +275,120 @@ async fn category_subtree_listing_and_price_sort() { assert_eq!(body["error"]["code"], "BAD_REQUEST"); } +#[tokio::test] +#[serial] +async fn brand_filter_and_real_sales() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + + // Brands are admin-managed reference data; replacing the list is idempotent. + let brands = serde_json::json!([ + {"slug": "alpha", "name": {"en": "Alpha", "zh": "阿尔法"}}, + {"slug": "beta", "name": {"en": "Beta", "zh": "贝塔"}} + ]); + let res = client() + .put(app.url("/api/admin/brands")) + .bearer_auth(&admin) + .json(&brands) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let list: serde_json::Value = res.json().await.unwrap(); + let alpha = list[0]["id"].as_str().unwrap().to_string(); + let beta = list[1]["id"].as_str().unwrap().to_string(); + + let shop_id = create_shop(&app, &admin, "shop-brands").await; + let owner = make_shop_owner(&app, &admin, &shop_id).await; + let (p_alpha, _) = + create_product_full(&app, &owner, "branded-a", 1000, 10, None, Some(&alpha)).await; + let (p_beta, _) = + create_product_full(&app, &owner, "branded-b", 2000, 10, None, Some(&beta)).await; + publish_product(&app, &owner, &p_alpha).await; + publish_product(&app, &owner, &p_beta).await; + + let ids = |body: &serde_json::Value| -> Vec { + body["items"] + .as_array() + .unwrap() + .iter() + .map(|p| p["id"].as_str().unwrap().to_string()) + .collect() + }; + let sold = |body: &serde_json::Value, id: &str| -> i64 { + body["items"] + .as_array() + .unwrap() + .iter() + .find(|p| p["id"] == id) + .unwrap()["sold_count"] + .as_i64() + .unwrap() + }; + + // The brand filter narrows within the shop. + let res = client() + .get(app.url(&format!( + "/api/products?shop_id={shop_id}&brand_id={alpha}&per_page=50" + ))) + .send() + .await + .unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(ids(&body), vec![p_alpha.clone()]); + assert_eq!(body["total"], 1); + + // An unpaid order is not a sale. + let sku_alpha: String = + sqlx::query_scalar("SELECT id::text FROM skus WHERE product_id = $1::uuid") + .bind(&p_alpha) + .fetch_one(&app.db) + .await + .unwrap(); + let (buyer, _) = register_customer(&app, "brand-buyer").await; + add_to_cart(&app, &buyer, &sku_alpha, 3).await; + let orders = checkout(&app, &buyer).await; + + let res = client() + .get(app.url(&format!("/api/products?shop_id={shop_id}&per_page=50"))) + .send() + .await + .unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(sold(&body, &p_alpha), 0, "pending payment must not count as sold"); + assert_eq!(sold(&body, &p_beta), 0); + + // Paying makes the units count, and the sales sort follows them. + for order in &orders { + pay(&app, &buyer, order["id"].as_str().unwrap()).await; + } + let res = client() + .get(app.url(&format!("/api/products?shop_id={shop_id}&per_page=50"))) + .send() + .await + .unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(sold(&body, &p_alpha), 3); + + let res = client() + .get(app.url(&format!( + "/api/products?shop_id={shop_id}&sort=sales&order=desc&per_page=50" + ))) + .send() + .await + .unwrap(); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(ids(&body)[0], p_alpha, "sales sort puts the sold product first"); + + // Comments still have no model, so that sort stays refused. + let res = client() + .get(app.url("/api/products?sort=comments")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); +} + #[tokio::test] #[serial] async fn suspended_shop_hidden_from_public_catalog() { diff --git a/apps/api/tests/common/mod.rs b/apps/api/tests/common/mod.rs index c4ea171..dcbc459 100644 --- a/apps/api/tests/common/mod.rs +++ b/apps/api/tests/common/mod.rs @@ -150,7 +150,7 @@ pub async fn create_product_with_sku( price_minor: i64, stock: i32, ) -> (String, String) { - create_product_with_sku_in_category(app, owner_token, slug, price_minor, stock, None).await + create_product_full(app, owner_token, slug, price_minor, stock, None, None).await } /// Same, but placed in `category_id` so category filtering can be exercised. @@ -161,6 +161,19 @@ pub async fn create_product_with_sku_in_category( price_minor: i64, stock: i32, category_id: Option<&str>, +) -> (String, String) { + create_product_full(app, owner_token, slug, price_minor, stock, category_id, None).await +} + +/// Same, with a category and a brand so both filters can be exercised. +pub async fn create_product_full( + app: &TestApp, + owner_token: &str, + slug: &str, + price_minor: i64, + stock: i32, + category_id: Option<&str>, + brand_id: Option<&str>, ) -> (String, String) { let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); let res = client() @@ -171,6 +184,7 @@ pub async fn create_product_with_sku_in_category( "name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")}, "description": {"en": "desc en", "zh": "描述"}, "category_id": category_id, + "brand_id": brand_id, })) .send() .await diff --git a/apps/mall/components/ui/ProductCard.vue b/apps/mall/components/ui/ProductCard.vue index 3528a4c..ad62ec2 100644 --- a/apps/mall/components/ui/ProductCard.vue +++ b/apps/mall/components/ui/ProductCard.vue @@ -1,14 +1,14 @@ diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index 716a80f..51f6a28 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -21,6 +21,7 @@ import type { import { BASE_CURRENCY, MOCK_BANNERS, + MOCK_BRANDS, MOCK_CATEGORIES, MOCK_CURRENCIES, MOCK_PROMOS, @@ -162,7 +163,10 @@ export function createMockApi(): ApiClient { searchMockProducts({ q: q.q, categoryId: q.category_id, + brandId: q.brand_id, shopId: q.shop_id, + sort: q.sort, + order: q.order, page: q.page, perPage: q.per_page, }), @@ -175,6 +179,7 @@ export function createMockApi(): ApiClient { }, listCategories: () => Promise.resolve(MOCK_CATEGORIES), + listBrands: () => Promise.resolve(MOCK_BRANDS), listCurrencies: () => Promise.resolve(MOCK_CURRENCIES), convert: (amountMinor, from, to) => Promise.resolve({ amount_minor: mockConvertMinor(amountMinor, from, to), currency: to }), @@ -375,8 +380,7 @@ export function createMockApi(): ApiClient { })), }), - listShops: (): Promise => Promise.resolve(MOCK_STORES.map(toShopProfile)), - getShop: (slug) => { + listShops: (): Promise => Promise.resolve(MOCK_STORES.map(toShopProfile)), getShop: (slug) => { const store = storeById(slug); if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found")); return Promise.resolve(toShopProfile(store)); @@ -412,6 +416,8 @@ export function createMockApi(): ApiClient { getContent: () => unsupported(), replaceContent: () => unsupported(), setShopProfile: () => unsupported(), + getBrands: () => unsupported(), + replaceBrands: () => unsupported(), }, }; } diff --git a/apps/mall/mock/data.ts b/apps/mall/mock/data.ts index 046e705..937db76 100644 --- a/apps/mall/mock/data.ts +++ b/apps/mall/mock/data.ts @@ -3,6 +3,7 @@ import type { Address, + Brand, Category, Invoice, LocalizedText, @@ -50,18 +51,6 @@ export interface MockPromo { url: string; } -export interface MockComment { - id: string; - productId: string; - author: string; - avatar: string; - rating: number; // 1-5 - content: LocalizedText; - images: string[]; - reply: LocalizedText | null; - createdAt: string; -} - export interface MockCoupon { id: string; title: LocalizedText; @@ -105,21 +94,6 @@ export interface IntegralProduct { stock: number; } -export interface ProductDetail { - product: Product; - store: MockStore; - comments: MockComment[]; - commentStats: { all: number; good: number; medium: number; bad: number; goodRate: number }; - coupons: MockCoupon[]; - salesRank: Product[]; -} - -export interface StoreDetail { - store: MockStore; - products: Product[]; - salesRank: Product[]; -} - // ---------- currencies ---------- export const MOCK_CURRENCIES = [ @@ -332,7 +306,15 @@ export function categorySubtreeIds(rootId: string): Set { // ---------- brands ---------- -const PRODUCT_BRAND: Record = {}; +/** Mirrors the seeded `brands` table so the rollback path can still filter. */ +export const MOCK_BRANDS: Brand[] = [ + { id: "b1", slug: "aurora", name: L("Aurora", "极光"), position: 0, active: true }, + { id: "b2", slug: "nordwind", name: L("Nordwind", "北风"), position: 1, active: true }, + { id: "b3", slug: "hexon", name: L("Hexon", "赫克森"), position: 2, active: true }, + { id: "b4", slug: "mikado", name: L("Mikado", "御门"), position: 3, active: true }, + { id: "b5", slug: "solace", name: L("Solace", "索莱斯"), position: 4, active: true }, + { id: "b6", slug: "terra", name: L("Terra", "大地"), position: 5, active: true }, +]; // ---------- stores ---------- @@ -442,7 +424,6 @@ function buildProducts(): Product[] { const now = "2026-09-01T00:00:00.000Z"; return PRODUCT_SPECS.map((spec, i) => { const id = `p${i + 1}`; - PRODUCT_BRAND[id] = spec.brand; const img = `/mock/product-${spec.n}.svg`; const skus: Sku[] = []; const combos = spec.attrs.reduce( @@ -470,12 +451,16 @@ function buildProducts(): Product[] { id, shop_id: spec.store, category_id: spec.cat, + brand_id: spec.brand, slug: spec.slug, name: spec.name, description: spec.sub, images: [img, img, img], status: "published", created_at: now, + // The fixed-data path has no order data to count, so it reports none + // rather than inventing a figure. + sold_count: 0, skus, } satisfies Product; }); @@ -501,28 +486,19 @@ export interface MockSearchQuery { categoryId?: string; brandId?: string; shopId?: string; - sort?: "default" | "price" | "sales" | "comments"; + sort?: "default" | "price" | "sales"; order?: "asc" | "desc"; page?: number; perPage?: number; } -// Deterministic pseudo stats so sort orders are stable. Seeded by hashing the -// id, not by parsing digits out of it: mock ids are "p1" but live ids are UUIDs, -// and `Number("f8a1...")` is NaN, which rendered as "NaN sold". +/** Stable ordering for the fixed-data catalogue; not a claim about sales. */ function seedOf(id: string): number { let hash = 0; for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) % 100000; return hash; } -export function salesOf(p: Product): number { - return 50 + ((seedOf(p.id) * 137) % 950); -} -export function commentCountOf(p: Product): number { - return 5 + ((seedOf(p.id) * 61) % 240); -} - export function searchMockProducts(query: MockSearchQuery): { items: Product[]; total: number; page: number; per_page: number } { const page = query.page && query.page > 0 ? query.page : 1; const perPage = query.perPage && query.perPage > 0 ? query.perPage : 20; @@ -532,7 +508,7 @@ export function searchMockProducts(query: MockSearchQuery): { items: Product[]; const ids = categorySubtreeIds(query.categoryId); list = list.filter((p) => p.category_id !== null && ids.has(p.category_id)); } - if (query.brandId) list = list.filter((p) => PRODUCT_BRAND[p.id] === query.brandId); + if (query.brandId) list = list.filter((p) => p.brand_id === query.brandId); const kw = query.q?.trim().toLowerCase(); if (kw) { list = list.filter((p) => @@ -547,10 +523,9 @@ export function searchMockProducts(query: MockSearchQuery): { items: Product[]; list = [...list].sort((a, b) => dir * ((lowestSku(a)?.price_minor ?? 0) - (lowestSku(b)?.price_minor ?? 0))); break; case "sales": - list = [...list].sort((a, b) => dir * (salesOf(a) - salesOf(b))); - break; - case "comments": - list = [...list].sort((a, b) => dir * (commentCountOf(a) - commentCountOf(b))); + // The fixed-data catalogue has no orders, so every product reports zero + // sold and this keeps the incoming order rather than inventing one. + list = [...list].sort((a, b) => dir * (a.sold_count - b.sold_count)); break; default: list = [...list].sort((a, b) => seedOf(a.id) - seedOf(b.id)); @@ -600,76 +575,6 @@ export const MOCK_COUPONS: MockCoupon[] = [ { id: "cp3", title: L("$40 off over $499", "满 499 减 40"), amountMinor: 4000, thresholdMinor: 49900, currency: BASE_CURRENCY, expiresAt: "2026-10-31" }, ]; -export function commentsFor(productId: string): MockComment[] { - const seed = Number(productId.replace(/\D/g, "")) || 1; - const pool: { author: string; text: LocalizedText; rating: number }[] = [ - { author: "A***a", text: L("Great quality, exactly as described. Fast shipping!", "质量很好,和描述一致,发货快!"), rating: 5 }, - { author: "M***e", text: L("Good value for the price. Would buy again.", "性价比不错,会回购。"), rating: 5 }, - { author: "J***n", text: L("Decent, but packaging could be better.", "还行,包装可以更好。"), rating: 4 }, - { author: "S***y", text: L("Average experience overall.", "整体一般。"), rating: 3 }, - ]; - const count = 2 + (seed % 3); - const out: MockComment[] = []; - for (let i = 0; i < count; i++) { - const p = pool[(seed + i) % pool.length]; - out.push({ - id: `${productId}-cm${i + 1}`, - productId, - author: p.author, - avatar: "/mock/avatar.svg", - rating: p.rating, - content: p.text, - images: [], - reply: - p.rating <= 3 - ? L("Sorry for the inconvenience, please contact support.", "很抱歉带来不便,请联系在线客服处理。") - : null, - createdAt: `2026-0${(seed % 8) + 1}-1${i}`, - }); - } - return out; -} - -export function commentStats(productId: string): { all: number; good: number; medium: number; bad: number; goodRate: number } { - // Derived from the id itself so it matches commentCountOf for live UUID ids - // too, instead of falling back to an arbitrary mock product. - const all = 5 + ((seedOf(productId) * 61) % 240); - const good = Math.round(all * 0.92); - const medium = Math.round(all * 0.06); - const bad = all - good - medium; - return { all, good, medium, bad, goodRate: Math.round((good / Math.max(1, all)) * 100) }; -} - -export function salesRankFor(shopId: string): Product[] { - return MOCK_PRODUCTS.filter((p) => p.shop_id === shopId) - .sort((a, b) => salesOf(b) - salesOf(a)) - .slice(0, 5); -} - -export function productDetail(idOrSlug: string): ProductDetail | null { - const product = productById(idOrSlug); - if (!product) return null; - const store = storeById(product.shop_id) ?? MOCK_STORES[0]; - return { - product, - store, - comments: commentsFor(product.id), - commentStats: commentStats(product.id), - coupons: MOCK_COUPONS, - salesRank: salesRankFor(product.shop_id), - }; -} - -export function storeDetail(idOrSlug: string): StoreDetail | null { - const store = storeById(idOrSlug); - if (!store) return null; - return { - store, - products: MOCK_PRODUCTS.filter((p) => p.shop_id === store.id), - salesRank: salesRankFor(store.id), - }; -} - // ---------- marketing ---------- export const SECKILL_SESSIONS: SeckillSession[] = [ diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index 924cfc7..a9bbe4e 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -9,7 +9,7 @@ export default defineNuxtConfig({ // Domains served by the live backend; every other domain stays on the // fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'. // See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3. - liveDomains: ["catalog", "currency", "content", "shops", "auth", "cart", "orders", "shipments", "invoices"], + liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "cart", "orders", "shipments", "invoices"], appName: "mall", }, }, diff --git a/apps/mall/pages/goods/[id].vue b/apps/mall/pages/goods/[id].vue index 9c5d648..5ccef4c 100644 --- a/apps/mall/pages/goods/[id].vue +++ b/apps/mall/pages/goods/[id].vue @@ -2,7 +2,7 @@ import { t as pick } from "@vmall/shared"; import { ApiError } from "@vmall/shared"; import type { Category, Product, Sku } from "@vmall/shared"; -import { MOCK_COUPONS, commentStats, commentsFor, salesOf } from "~/mock/data"; +import { MOCK_COUPONS } from "~/mock/data"; import { lowestSku } from "~/utils/product"; import { useCartStore } from "~/stores/cart"; @@ -27,8 +27,8 @@ const { data: pageData } = await useAsyncData( const current = await $api.getProduct(routeId.value); // The rail uses live catalogue products so its links resolve; the store // card, comments and coupons stay local display-only content (non-goals). - // Store best sellers, mirroring the mock's salesRankFor(shopId). Ranking by - // category would empty the rail for any product alone in its leaf category. + // Store best sellers, ranking the shop's own products. Ranking by category + // would empty the rail for any product alone in its leaf category. const siblings = await $api.listProducts({ shop_id: current.shop_id, per_page: 6, @@ -54,15 +54,11 @@ const detail = computed(() => { product: current, // Null when the shop has no public profile; the card is then not rendered. store: shops.value.find((shop) => shop.id === current.shop_id) ?? null, - comments: commentsFor(current.id), - commentStats: commentStats(current.id), coupons: MOCK_COUPONS, salesRank: related.value, }; }); -/// Display-only sales figure; `salesOf` hashes the id so it is safe for UUIDs. - const store = computed(() => detail.value?.store ?? null); const selectedAttributes = reactive>({}); const quantity = ref(1); @@ -174,9 +170,10 @@ const addCart = async (): Promise => { } }; +// No reviews tab: there is no reviews capability, and the mall will not present +// invented reviewers and ratings as fact. See the wave-6 design. const tabs = computed(() => [ { key: "detail", label: t("product.tabsDetail") }, - { key: "reviews", label: t("product.tabsReviews") }, { key: "service", label: t("product.tabsAfterSale") }, ]); @@ -242,8 +239,7 @@ const detailImages = computed(() => product.value?.images ?? []); {{ t("product.marketPrice") }}
- {{ t("product.sold", { n: salesOf(detail.product) }) }} - {{ t("product.commentCount", { n: detail.commentStats.all }) }} + {{ t("product.sold", { n: detail.product.sold_count }) }} {{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}
@@ -324,20 +320,6 @@ const detailImages = computed(() => product.value?.images ?? []);

{{ pick(detail.product.description, locale) }}

-
-
- {{ t("product.reviewSummary", { rate: detail.commentStats.goodRate, n: detail.commentStats.all }) }} - {{ t("product.reviewAll") }} -
-
- -
-
{{ comment.author }}
-

{{ pick(comment.content, locale) }}

-
{{ t("product.reply") }}: {{ pick(comment.reply, locale) }}
-
-
-

{{ t("product.tabsAfterSale") }}

{{ pick(detail.store.after_sale, locale) }}

diff --git a/apps/mall/pages/search.vue b/apps/mall/pages/search.vue index c3119ed..e721805 100644 --- a/apps/mall/pages/search.vue +++ b/apps/mall/pages/search.vue @@ -1,10 +1,10 @@ @@ -155,6 +171,19 @@ const sortOptions = computed(() => [ >{{ pick(category.name, locale) }}
+
+ {{ t("search.brand") }} +
+ + +
+
{{ t("search.sort") }}
diff --git a/apps/mall/plugins/api.ts b/apps/mall/plugins/api.ts index a8730a4..fa34f85 100644 --- a/apps/mall/plugins/api.ts +++ b/apps/mall/plugins/api.ts @@ -13,6 +13,7 @@ type LiveDomain = | "currency" | "content" | "shops" + | "brands" | "cart" | "orders" | "shipments" @@ -33,6 +34,7 @@ const LIVE_PICKS = { currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }), content: (a: ApiClient) => ({ getHomeContent: a.getHomeContent }), shops: (a: ApiClient) => ({ listShops: a.listShops, getShop: a.getShop }), + brands: (a: ApiClient) => ({ listBrands: a.listBrands }), cart: (a: ApiClient) => ({ getCart: a.getCart, addCartItem: a.addCartItem, @@ -64,6 +66,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [ "currency", "content", "shops", + "brands", "auth", "cart", "orders", diff --git a/docs/TBD-migrate-wave.md b/docs/TBD-migrate-wave.md index 0cfc37d..a2ff3ce 100644 --- a/docs/TBD-migrate-wave.md +++ b/docs/TBD-migrate-wave.md @@ -50,8 +50,8 @@ Each is a new backend capability rather than a domain flip. - [x] **Storefront content** — banners, promos, quick links and floor advert art. Done in `replace-mock-api-wave-4`: four tables seeded from the existing assets, a public `GET /api/content/home`, and an admin read/replace pair. - [x] **Public store read** — a buyer-facing shop endpoint so `stores/index`, `stores/[id]` and the cart's shop grouping leave mock. Done in `replace-mock-api-wave-5`: a `shop_profiles` table, `GET /api/shops` + `GET /api/shops/{slug}`, an admin upsert, and the order surfaces now name their shop. Two things went rather than being faked: `distanceKm` (no geo model) and the store home's "best sellers" rail plus its sales/comments sorts (no sales model). -- [ ] **Brand model + sales/comments sorts** — restores the brand facet and the sorts removed in Wave 1. Needs a `brands` table (`products.brand_id` + i18n) plus sales and comments data, neither of which exists today. -- [ ] Extend the `ORDER BY` whitelist if more sorts are wanted beyond the `sort=price` added in Wave 1. +- [x] **Brand model + sales/comments sorts** — Done in `replace-mock-api-wave-6` for the two that have a model: a `brands` table with a product column, a public read and an ordered admin replace, so the search facet is back; and `sort=sales` computed from `order_items` over orders that reached payment, with a real `sold_count` on every product payload. **Comments are not done and cannot be**: there is no reviews model, so the review UI went rather than staying as invented reviewers and ratings. Reviews are future work — see the out-of-scope note below. +- [x] Extend the `ORDER BY` whitelist. Nothing more is wanted: `price` and `sales` are the two orderings with a model behind them, and any other value is a 400 by design. - [ ] *(adjacent, not part of the migration)* Move the session token to a cookie so SSR knows whether anyone is signed in. Today a full page load of a guarded route renders the page and then redirects on the client, which logs a hydration mismatch; it is pre-existing (verified identical before Wave 2) and harmless, but it is the real fix for the `ClientOnly` workarounds in `components/shell/TopBar.vue` and `pages/user.vue`. --- @@ -62,6 +62,7 @@ These have no API contract and no backend model. Leaving them on `~/mock/data` i - **Addresses** — never a blocker: `Address` is embedded in the order and live checkout takes it in the request body, so no addresses table is needed. `MOCK_ADDRESSES` can stay behind checkout indefinitely. - **Favorites, coupons, account stats** — pure presentation, no transactional impact. +- **Reviews** — the mall no longer presents any: the card's review count and the product detail page's reviews tab, summary and replies were removed in Wave 6 rather than kept as invented reviewers and ratings. Writing, moderating and displaying reviews is a feature with its own lifecycle, not a migration. - **seckill / collective / integral marketing pages** — display-only mock content. ## Decisions already made (do not relitigate) diff --git a/openspec/changes/replace-mock-api-wave-6/tasks.md b/openspec/changes/replace-mock-api-wave-6/tasks.md index 45f51ea..58e353b 100644 --- a/openspec/changes/replace-mock-api-wave-6/tasks.md +++ b/openspec/changes/replace-mock-api-wave-6/tasks.md @@ -2,34 +2,35 @@ ## 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 +- [x] 1.1 Add a migration creating `brands` (bilingual name, slug, position, active) and `products.brand_id` nullable with `ON DELETE SET NULL`; verified the table and column exist after `cargo run -p vmall-api` +- [x] 1.2 Seed the six demo brands and assign them to the demo products; verified a re-run is idempotent and all 24 products end up with a brand. The seed re-applies each product body on the 409 path so a re-run converges the assignment, and the brand list is captured in a local const — reading it back through the shared `r` variable worked once and then broke, because the product loop reassigns it ## 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 +- [x] 2.1 Add `Brand` and `BrandInput`, the optional `brand_id` on the product payload and `ProductUpsertBody`, `brand_id` on `ProductListQuery`, `"sales"` on its `sort`, and `sold_count` on `Product`; verified all three frontends build +- [x] 2.2 Add `listBrands()`, `admin.getBrands()` / `admin.replaceBrands(list)` and fixed-data implementations, and register a `brands` domain in the per-domain switch. Also taught the fixed-data `listProducts` to pass `brand_id`, `sort` and `order` through — it had silently ignored all three, so the restored facet rendered but filtered nothing until the rollback check caught it ## 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 +- [x] 3.1 Add public `GET /api/brands` and admin `PUT /api/admin/brands` (transactional replace, slug and bilingual-name validation); verified a non-admin is refused and a duplicate slug is a 400 +- [x] 3.2 Add the `brand_id` filter to `listProducts`, composing with the other filters, and return `brand_id` on the payload; verified a brand filter narrows 24 products to 6 and composes with the shop filter +- [x] 3.3 Accept `brand_id` in the shop product upsert, on both create and update, so a merchant — and the seed — can set or clear it ## 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 +- [x] 4.1 Compute `sold_count` per product from `order_items` joined to orders in `paid`, `fulfilling`, `shipped` or `completed`, exposed on the list and detail payloads; verified a product with no paid orders reports zero +- [x] 4.2 Accept `sort=sales` with `order`, keeping the 400 for any other value; verified the order matches the reported counts and that a `pending_payment` order moves nothing +- [x] 4.3 Extend `apps/api/tests/catalog.rs` with `brand_filter_and_real_sales`, covering the brand filter, the unpaid exclusion, the paid count and the sales order; verified `cargo test -p vmall-api` is green at 29 tests 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 +- [x] 5.1 `pages/search.vue`: restored the brand facet from `listBrands()` and the sales sort; verified the facet renders only when brands exist and that brand plus category compose +- [x] 5.2 `components/ui/ProductCard.vue`: shows the product's real `sold_count` and no review figure; verified the card reads "N sold" only +- [x] 5.3 `pages/goods/[id].vue`: shows the real sold count, and the reviews tab, its summary and reply blocks are gone; verified the page offers only the detail and after-sale tabs, keeps the store card, and still shows the shop's after-sale copy +- [x] 5.4 Removed the now-unreferenced fabrication cluster from `apps/mall/mock/data.ts` — `salesOf`, `commentCountOf`, `commentsFor`, `commentStats`, `salesRankFor`, `productDetail`, `storeDetail` and their types. Nothing imported them once the review UI went, and the fixed-data sales sort now orders by `sold_count`, which is zero there rather than an invented number ## 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 +- [x] 6.1 All three frontends build and `cargo test -p vmall-api` is green at 29 tests. The browser check remains the real gate, since `nuxt build` does not typecheck +- [x] 6.2 Verified live in a browser: the search page shows the brand facet and a Sales sort, filtering by a brand narrows 24 products to 6, descending sales puts the sold products first with counts matching the API, product cards show only a real sold count, and the product page has no reviews while keeping its after-sale copy and store card +- [x] 6.3 Verified the rollback: with every domain on fixed data and the backend stopped, the facet renders the fixed-data brands and filtering by one narrows 24 products to 2 diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 4cfd95b..2f65fa9 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -1,6 +1,8 @@ import type { Address, AuthTokens, + Brand, + BrandInput, Cart, Category, ContentInputByKind, @@ -44,15 +46,17 @@ export interface ProductListQuery { page?: number; per_page?: number; category_id?: string; + brand_id?: string; q?: string; shop_id?: string; - /** `price` orders by each product's lowest active SKU price. */ - sort?: "price"; + /** `price` orders by lowest active SKU price; `sales` by units sold. */ + sort?: "price" | "sales"; order?: "asc" | "desc"; } export interface ProductUpsertBody { category_id?: string | null; + brand_id?: string | null; slug: string; name: LocalizedText; description?: LocalizedText; @@ -145,6 +149,7 @@ export interface ApiClient { listProducts(q?: ProductListQuery): Promise>; getProduct(idOrSlug: string): Promise; listCategories(): Promise; + listBrands(): Promise; listCurrencies(): Promise; convert(amountMinor: number, from: string, to: string): Promise; getCart(): Promise; @@ -223,6 +228,7 @@ export function createApi(opts: ApiClientOptions): ApiClient { listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }), getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`), listCategories: () => r("GET", "/categories"), + listBrands: () => r("GET", "/brands"), listCurrencies: () => r("GET", "/currencies"), convert: (amountMinor, from, to) => r("GET", "/currencies/convert", undefined, { amount_minor: amountMinor, from, to }), @@ -282,6 +288,9 @@ export function createApi(opts: ApiClientOptions): ApiClient { getContent: () => r("GET", "/admin/content"), replaceContent: (kind, items) => r("PUT", `/admin/content/${kind}`, items), setShopProfile: (id, body) => r("PUT", `/admin/shops/${id}/profile`, body), + /** Replaces the whole ordered brand list. */ + getBrands: () => r("GET", "/brands"), + replaceBrands: (items) => r("PUT", "/admin/brands", items), }, }; } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 0d0a9b5..46c39db 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -50,15 +50,32 @@ export interface Product { id: string; shop_id: string; category_id: string | null; + brand_id: string | null; slug: string; name: LocalizedText; description: LocalizedText; images: string[]; status: ProductStatus; created_at: string; + /** Units sold across paid orders; every product payload carries it. */ + sold_count: number; skus?: Sku[]; } +export interface Brand { + id: string; + name: LocalizedText; + slug: string; + position: number; + active: boolean; +} + +export interface BrandInput { + slug: string; + name: LocalizedText; + active?: boolean; +} + export interface Sku { id: string; product_id: string; diff --git a/scripts/seed-demo.mjs b/scripts/seed-demo.mjs index 88a4b6a..7d9246e 100755 --- a/scripts/seed-demo.mjs +++ b/scripts/seed-demo.mjs @@ -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`, {