From c05c4fafdbeef21fba12010bfc29293b6760c581 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 11 Aug 2026 10:46:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B1=95=E7=A4=BA=E9=A1=BA=E8=BF=90?= =?UTF-8?q?=E5=AE=9D=E8=A7=82=E6=B5=8B=E8=A7=84=E6=A0=BC=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/main_test.go | 13 +++++ admin/repository/syb.go | 53 +++++++++++++++++++- admin/repository/syb_test.go | 19 +++++++ admin/service/shopee_list.go | 36 ++++++++++++- admin/service/shopee_list_test.go | 64 ++++++++++++++++++++++++ admin/templates/shopee/detail_modal.html | 8 +++ docs/admin/03-data-model.md | 6 +++ docs/admin/05-ui-specification.md | 3 ++ 8 files changed, 199 insertions(+), 3 deletions(-) diff --git a/admin/main_test.go b/admin/main_test.go index cc56002..abe38e1 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -138,6 +138,19 @@ func TestShopeePage_移除Excel入口且保留目录记录入口(t *testing.T) { } } +func TestShopeeDetail_区分顺运宝观测与正式SKU(t *testing.T) { + content, err := os.ReadFile("templates/shopee/detail_modal.html") + if err != nil { + t.Fatal(err) + } + page := string(content) + for _, want := range []string{"顺运宝观测规格", "不是正式蝦皮 SKU", "最近货运单售价(台币)", "{{.MatchText}}"} { + if !strings.Contains(page, want) { + t.Errorf("蝦皮详情缺少 %q", want) + } + } +} + func TestShopeeImportRoute_已移除(t *testing.T) { router, err := newRouter(nil) if err != nil { diff --git a/admin/repository/syb.go b/admin/repository/syb.go index 315490a..e42ebc7 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -259,7 +259,15 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) { if _, err := q.Exec(` INSERT INTO shopee_products (goods_id, title, source, created_at, updated_at) VALUES (?, ?, 'syb', ?, ?) - ON DUPLICATE KEY UPDATE goods_id=VALUES(goods_id)`, + ON DUPLICATE KEY UPDATE + title = CASE + WHEN source = 'syb' AND TRIM(VALUES(title)) <> '' THEN VALUES(title) + ELSE title + END, + updated_at = CASE + WHEN source = 'syb' AND TRIM(VALUES(title)) <> '' THEN VALUES(updated_at) + ELSE updated_at + END`, o.ShopeeGoodsID, o.Title, now, now); err != nil { return false, fmt.Errorf("为顺运宝明细 %s 补建蝦皮商品骨架失败: %w", o.SybID, err) } @@ -289,6 +297,49 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) { return created, nil } +// SybSpecObservation 是顺运宝对某个蝦皮商品规格的历史观测汇总。 +// 它不是正式蝦皮 SKU,不得写回 shopee_skus。 +type SybSpecObservation struct { + SpecKey string + SpecRaw string + LatestPriceCent int64 + LatestImageURL string + OrderCount int + TotalQuantity int + LastObservedAt string +} + +// ListSybSpecObservations 按规格身份汇总货运单明细;同一 syb_id 重复同步仍只有一行。 +func ListSybSpecObservations(q Execer, goodsID string) ([]SybSpecObservation, error) { + rows, err := q.Query(`WITH ranked AS ( + SELECT spec_key,product_spec,price_twd_cent,image_url,quantity,created_at,syb_id, + ROW_NUMBER() OVER(PARTITION BY spec_key ORDER BY created_at DESC,syb_id DESC) AS row_num, + COUNT(*) OVER(PARTITION BY spec_key) AS order_count, + SUM(quantity) OVER(PARTITION BY spec_key) AS total_quantity, + MAX(created_at) OVER(PARTITION BY spec_key) AS last_observed_at + FROM syb_orders + WHERE shopee_goods_id=? AND spec_key IS NOT NULL AND spec_key<>'' + ) + SELECT spec_key,product_spec,price_twd_cent,image_url,order_count,total_quantity,last_observed_at + FROM ranked WHERE row_num=1 ORDER BY last_observed_at DESC,spec_key`, goodsID) + if err != nil { + return nil, fmt.Errorf("查询蝦皮商品 %s 的顺运宝观测规格失败: %w", goodsID, err) + } + defer rows.Close() + var list []SybSpecObservation + for rows.Next() { + var item SybSpecObservation + var raw, image sql.NullString + if err := rows.Scan(&item.SpecKey, &raw, &item.LatestPriceCent, &image, &item.OrderCount, &item.TotalQuantity, &item.LastObservedAt); err != nil { + return nil, fmt.Errorf("读取顺运宝观测规格失败: %w", err) + } + item.SpecRaw = raw.String + item.LatestImageURL = image.String + list = append(list, item) + } + return list, rows.Err() +} + // nullableText 把空字符串转成 SQL NULL,非空字符串原样写入。 // syb_orders 的这几列在建表语句里都允许 NULL,空字符串和 NULL // 在页面上显示效果一样,统一存 NULL 更符合"这个字段还没有值"的语义。 diff --git a/admin/repository/syb_test.go b/admin/repository/syb_test.go index f2f8081..704f19f 100644 --- a/admin/repository/syb_test.go +++ b/admin/repository/syb_test.go @@ -121,6 +121,25 @@ func TestUpsertSybOrder_已有的ShopeeSKUID同步后仍在(t *testing.T) { } } +func TestUpsertSybOrder_只更新Syb骨架标题(t *testing.T) { + db := newSybTestDB(t) + now := model.NowISO() + if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('SYB-P','旧骨架','syb',?,?),('API-P','正式标题','api',?,?)`, now, now, now, now); err != nil { + t.Fatal(err) + } + for _, o := range []model.SybOrder{{SybID: "S1", OrderNo: "O1", Title: "新骨架", ProductSpec: "黑色,M", ShopeeGoodsID: "SYB-P", Quantity: 1, PriceTwdCent: 100, SybData: "{}"}, {SybID: "S2", OrderNo: "O2", Title: "顺运宝标题", ProductSpec: "白色,L", ShopeeGoodsID: "API-P", Quantity: 1, PriceTwdCent: 200, SybData: "{}"}} { + if _, err := UpsertSybOrder(db, o); err != nil { + t.Fatal(err) + } + } + var sybTitle, apiTitle string + _ = db.QueryRow(`SELECT title FROM shopee_products WHERE goods_id='SYB-P'`).Scan(&sybTitle) + _ = db.QueryRow(`SELECT title FROM shopee_products WHERE goods_id='API-P'`).Scan(&apiTitle) + if sybTitle != "新骨架" || apiTitle != "正式标题" { + t.Fatalf("来源保护失败:syb=%q api=%q", sybTitle, apiTitle) + } +} + func TestUpsertSybOrder_ShopeeGoodsID会被同步更新(t *testing.T) { db := newSybTestDB(t) diff --git a/admin/service/shopee_list.go b/admin/service/shopee_list.go index 5153550..09e2ab1 100644 --- a/admin/service/shopee_list.go +++ b/admin/service/shopee_list.go @@ -5,10 +5,12 @@ package service import ( "database/sql" + "fmt" "strings" "cmautobuy/admin/model" "cmautobuy/admin/repository" + "cmautobuy/admin/spec" ) // ShopeeProductView 是列表页一行要显示的全部内容,全部已经是字符串, @@ -215,8 +217,16 @@ type ShopeeProductDetail struct { CollectMsg string CanCollect bool - SKUs []ShopeeSpecView - PendingCount int + SKUs []ShopeeSpecView + PendingCount int + SybObservations []SybObservationView +} + +// SybObservationView 是详情页只读的顺运宝历史观测,不是正式 SKU。 +type SybObservationView struct { + SpecRaw, PriceText, ImageURL, LastObservedAt string + OrderCount, TotalQuantity int + MatchedSKUID, Color, Size, MatchText string } // GetShopeeProductDetail 读一个蝦皮商品的详情(商品信息 + 完整规格表)。 @@ -287,5 +297,27 @@ func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, e } d.SKUs = append(d.SKUs, row) } + observations, err := repository.ListSybSpecObservations(db, goodsID) + if err != nil { + return nil, err + } + formalByKey := map[string][]model.ShopeeSKU{} + for _, sk := range skus { + if key, keyErr := spec.SpecKey(sk.SpecRaw); keyErr == nil { + formalByKey[key] = append(formalByKey[key], sk) + } + } + for _, observation := range observations { + view := SybObservationView{SpecRaw: observation.SpecRaw, PriceText: fmt.Sprintf("NT$%.2f", float64(observation.LatestPriceCent)/100), ImageURL: observation.LatestImageURL, OrderCount: observation.OrderCount, TotalQuantity: observation.TotalQuantity, LastObservedAt: formatLocalTime(observation.LastObservedAt), MatchText: "待目录补全"} + if matches := formalByKey[observation.SpecKey]; len(matches) == 1 { + view.MatchedSKUID = matches[0].SKUID + view.Color = matches[0].Color + view.Size = matches[0].Size + view.MatchText = "唯一匹配" + } else if len(matches) > 1 { + view.MatchText = "多条匹配,待人工确认" + } + d.SybObservations = append(d.SybObservations, view) + } return d, nil } diff --git a/admin/service/shopee_list_test.go b/admin/service/shopee_list_test.go index afaaa32..4b60c78 100644 --- a/admin/service/shopee_list_test.go +++ b/admin/service/shopee_list_test.go @@ -36,6 +36,70 @@ func setShopeePddLink(t *testing.T, db *sql.DB, goodsID, pddGoodsID, pddURL stri } } +func TestGetShopeeProductDetail_汇总顺运宝观测且唯一匹配正式SKU(t *testing.T) { + db := newTestDB(t) + seedShopeeProduct(t, db, "S-OBS", "观测商品") + seedShopeeSKU(t, db, "SKU-OBS", "S-OBS", "黑色,M", "黑色", "M", "", true) + now1, now2 := "2026-08-10T00:00:00.000000000Z", "2026-08-11T00:00:00.000000000Z" + for _, row := range []struct { + id string + qty int + price int64 + image, at string + }{ + {"OBS-1", 2, 23900, "https://example.invalid/old.jpg", now1}, + {"OBS-2", 3, 25900, "https://example.invalid/new.jpg", now2}, + } { + _, err := db.Exec(`INSERT INTO syb_orders(syb_id,order_no,title,product_spec,spec_key,shopee_goods_id,quantity,price_twd_cent,image_url,syb_data,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,'{}',?,?)`, row.id, "ORDER-"+row.id, "观测商品", "黑色,M", "黑色,M", "S-OBS", row.qty, row.price, row.image, row.at, row.at) + if err != nil { + t.Fatal(err) + } + } + detail, err := GetShopeeProductDetail(db, "S-OBS") + if err != nil { + t.Fatal(err) + } + if len(detail.SybObservations) != 1 { + t.Fatalf("观测规格数=%d", len(detail.SybObservations)) + } + got := detail.SybObservations[0] + if got.OrderCount != 2 || got.TotalQuantity != 5 || got.PriceText != "NT$259.00" || got.ImageURL != "https://example.invalid/new.jpg" { + t.Fatalf("汇总错误:%+v", got) + } + if got.MatchedSKUID != "SKU-OBS" || got.MatchText != "唯一匹配" { + t.Fatalf("正式 SKU 匹配错误:%+v", got) + } + // 同一 syb_id 只是更新,不会产生第三个观测订单。 + if _, err := db.Exec(`UPDATE syb_orders SET quantity=4 WHERE syb_id='OBS-2'`); err != nil { + t.Fatal(err) + } + detail, _ = GetShopeeProductDetail(db, "S-OBS") + if detail.SybObservations[0].OrderCount != 2 || detail.SybObservations[0].TotalQuantity != 6 { + t.Fatalf("重放后统计错误:%+v", detail.SybObservations[0]) + } +} + +func TestGetShopeeProductDetail_多条正式SKU不猜测(t *testing.T) { + db := newTestDB(t) + seedShopeeProduct(t, db, "S-MULTI", "多匹配") + seedShopeeSKU(t, db, "SKU-1", "S-MULTI", "黑色,M", "黑色", "M", "", true) + seedShopeeSKU(t, db, "SKU-2", "S-MULTI", "黑色,M", "黑色", "M", "", true) + now := "2026-08-11T00:00:00.000000000Z" + _, err := db.Exec(`INSERT INTO syb_orders(syb_id,order_no,title,product_spec,spec_key,shopee_goods_id,quantity,price_twd_cent,syb_data,created_at,updated_at) VALUES('O-1','ORDER','多匹配','黑色,M','黑色,M','S-MULTI',1,100,'{}',?,?)`, now, now) + if err != nil { + t.Fatal(err) + } + detail, err := GetShopeeProductDetail(db, "S-MULTI") + if err != nil { + t.Fatal(err) + } + got := detail.SybObservations[0] + if got.MatchedSKUID != "" || got.MatchText != "多条匹配,待人工确认" { + t.Fatalf("不应猜测:%+v", got) + } +} + // ── 列表:商品级聚合 ────────────────────────────────── func TestListShopeeProducts_商品级一行(t *testing.T) { diff --git a/admin/templates/shopee/detail_modal.html b/admin/templates/shopee/detail_modal.html index f17c7ab..18d4da7 100644 --- a/admin/templates/shopee/detail_modal.html +++ b/admin/templates/shopee/detail_modal.html @@ -65,6 +65,14 @@ {{end}} + +

顺运宝观测规格({{len .SybObservations}} 个)

+

以下是历史货运单观测数据,不是正式蝦皮 SKU;价格为最近货运单售价(台币),不是 PDD 人民币采购价。

+ {{if .SybObservations}} +
+ {{range .SybObservations}}{{end}} +
规格原文最近售价订单数累计数量最近图片最后出现正式 SKU 匹配
{{.SpecRaw}}{{.PriceText}}{{.OrderCount}}{{.TotalQuantity}}{{if .ImageURL}}顺运宝观测图片{{else}}—{{end}}{{.LastObservedAt}}{{.MatchText}}{{if .MatchedSKUID}}:{{.MatchedSKUID}}({{.Color}} / {{.Size}}){{end}}
+ {{else}}

顺运宝尚未观测到这个商品的有效规格。

{{end}} diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 9fcefa6..5576dcc 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -429,6 +429,12 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC); 仍存在于最新 `skus_json`。 - `shopee_sku_id` 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。 +蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”: +订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。 +该读模型不写入 `shopee_skus`。只有同一商品下恰好一条正式 SKU 的 `spec_raw` +经 `SpecKey()` 后相等时才展示关联;零条或多条都不猜测、不写回。重复同步同一个 +`syb_id` 只更新原行,因此不会重复累计。 + `[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项), 不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是 `details[].id`,不是货运单本身的 `id`。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index de3b329..c66b036 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -816,3 +816,6 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD - `[建议]` 搜索框支持回车提交。 - `[建议]` 主要操作支持键盘 Tab 到达。 - `[必须]` 金额显示带币种符号,台币和人民币要能一眼分清(`NT$` / `¥`)。 +商品详情在正式 SKU 表之后显示“顺运宝观测规格”。该区域必须明确标注它不是正式 +蝦皮 SKU,价格是最近货运单台币售价、不是 PDD 人民币采购价;展示规格原文、订单 +数、累计数量、最近图片、最后出现时间及唯一正式 SKU 匹配结果,多条匹配交给人工。