feat: 展示顺运宝观测规格 (#140)

This commit is contained in:
chengma
2026-08-11 10:46:11 +08:00
parent 9181329917
commit c05c4fafdb
8 changed files with 199 additions and 3 deletions
+13
View File
@@ -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) { func TestShopeeImportRoute_已移除(t *testing.T) {
router, err := newRouter(nil) router, err := newRouter(nil)
if err != nil { if err != nil {
+52 -1
View File
@@ -259,7 +259,15 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
if _, err := q.Exec(` if _, err := q.Exec(`
INSERT INTO shopee_products (goods_id, title, source, created_at, updated_at) INSERT INTO shopee_products (goods_id, title, source, created_at, updated_at)
VALUES (?, ?, 'syb', ?, ?) 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 { o.ShopeeGoodsID, o.Title, now, now); err != nil {
return false, fmt.Errorf("为顺运宝明细 %s 补建蝦皮商品骨架失败: %w", o.SybID, err) 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 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,非空字符串原样写入。 // nullableText 把空字符串转成 SQL NULL,非空字符串原样写入。
// syb_orders 的这几列在建表语句里都允许 NULL,空字符串和 NULL // syb_orders 的这几列在建表语句里都允许 NULL,空字符串和 NULL
// 在页面上显示效果一样,统一存 NULL 更符合"这个字段还没有值"的语义。 // 在页面上显示效果一样,统一存 NULL 更符合"这个字段还没有值"的语义。
+19
View File
@@ -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) { func TestUpsertSybOrder_ShopeeGoodsID会被同步更新(t *testing.T) {
db := newSybTestDB(t) db := newSybTestDB(t)
+34 -2
View File
@@ -5,10 +5,12 @@ package service
import ( import (
"database/sql" "database/sql"
"fmt"
"strings" "strings"
"cmautobuy/admin/model" "cmautobuy/admin/model"
"cmautobuy/admin/repository" "cmautobuy/admin/repository"
"cmautobuy/admin/spec"
) )
// ShopeeProductView 是列表页一行要显示的全部内容,全部已经是字符串, // ShopeeProductView 是列表页一行要显示的全部内容,全部已经是字符串,
@@ -215,8 +217,16 @@ type ShopeeProductDetail struct {
CollectMsg string CollectMsg string
CanCollect bool CanCollect bool
SKUs []ShopeeSpecView SKUs []ShopeeSpecView
PendingCount int PendingCount int
SybObservations []SybObservationView
}
// SybObservationView 是详情页只读的顺运宝历史观测,不是正式 SKU。
type SybObservationView struct {
SpecRaw, PriceText, ImageURL, LastObservedAt string
OrderCount, TotalQuantity int
MatchedSKUID, Color, Size, MatchText string
} }
// GetShopeeProductDetail 读一个蝦皮商品的详情(商品信息 + 完整规格表)。 // GetShopeeProductDetail 读一个蝦皮商品的详情(商品信息 + 完整规格表)。
@@ -287,5 +297,27 @@ func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, e
} }
d.SKUs = append(d.SKUs, row) 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 return d, nil
} }
+64
View File
@@ -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) { func TestListShopeeProducts_商品级一行(t *testing.T) {
+8
View File
@@ -65,6 +65,14 @@
</table> </table>
</div> </div>
{{end}} {{end}}
<h3>顺运宝观测规格({{len .SybObservations}} 个)</h3>
<p class="hint">以下是历史货运单观测数据,不是正式蝦皮 SKU;价格为最近货运单售价(台币),不是 PDD 人民币采购价。</p>
{{if .SybObservations}}
<div class="table-wrap"><table><thead><tr><th>规格原文</th><th>最近售价</th><th>订单数</th><th>累计数量</th><th>最近图片</th><th>最后出现</th><th>正式 SKU 匹配</th></tr></thead><tbody>
{{range .SybObservations}}<tr><td>{{.SpecRaw}}</td><td>{{.PriceText}}</td><td>{{.OrderCount}}</td><td>{{.TotalQuantity}}</td><td>{{if .ImageURL}}<a href="{{.ImageURL}}" target="_blank" rel="noopener"><img src="{{.ImageURL}}" alt="顺运宝观测图片" class="thumb" loading="lazy"></a>{{else}}—{{end}}</td><td>{{.LastObservedAt}}</td><td>{{.MatchText}}{{if .MatchedSKUID}}:{{.MatchedSKUID}}({{.Color}} / {{.Size}}){{end}}</td></tr>{{end}}
</tbody></table></div>
{{else}}<p class="hint">顺运宝尚未观测到这个商品的有效规格。</p>{{end}}
</div> </div>
<div class="modal-foot"><button type="button" data-modal-close>关闭</button></div> <div class="modal-foot"><button type="button" data-modal-close>关闭</button></div>
+6
View File
@@ -429,6 +429,12 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
仍存在于最新 `skus_json`。 仍存在于最新 `skus_json`。
- `shopee_sku_id` 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。 - `shopee_sku_id` 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。
蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”:
订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。
该读模型不写入 `shopee_skus`。只有同一商品下恰好一条正式 SKU 的 `spec_raw`
经 `SpecKey()` 后相等时才展示关联;零条或多条都不猜测、不写回。重复同步同一个
`syb_id` 只更新原行,因此不会重复累计。
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项), `[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是 不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
`details[].id`,不是货运单本身的 `id`。 `details[].id`,不是货运单本身的 `id`。
+3
View File
@@ -816,3 +816,6 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
- `[建议]` 搜索框支持回车提交。 - `[建议]` 搜索框支持回车提交。
- `[建议]` 主要操作支持键盘 Tab 到达。 - `[建议]` 主要操作支持键盘 Tab 到达。
- `[必须]` 金额显示带币种符号,台币和人民币要能一眼分清(`NT$` / `¥`)。 - `[必须]` 金额显示带币种符号,台币和人民币要能一眼分清(`NT$` / `¥`)。
商品详情在正式 SKU 表之后显示“顺运宝观测规格”。该区域必须明确标注它不是正式
蝦皮 SKU,价格是最近货运单台币售价、不是 PDD 人民币采购价;展示规格原文、订单
数、累计数量、最近图片、最后出现时间及唯一正式 SKU 匹配结果,多条匹配交给人工。