feat: 展示顺运宝观测规格 (#140)
This commit is contained in:
@@ -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
@@ -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 更符合"这个字段还没有值"的语义。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
@@ -816,3 +816,6 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
|
|||||||
- `[建议]` 搜索框支持回车提交。
|
- `[建议]` 搜索框支持回车提交。
|
||||||
- `[建议]` 主要操作支持键盘 Tab 到达。
|
- `[建议]` 主要操作支持键盘 Tab 到达。
|
||||||
- `[必须]` 金额显示带币种符号,台币和人民币要能一眼分清(`NT$` / `¥`)。
|
- `[必须]` 金额显示带币种符号,台币和人民币要能一眼分清(`NT$` / `¥`)。
|
||||||
|
商品详情在正式 SKU 表之后显示“顺运宝观测规格”。该区域必须明确标注它不是正式
|
||||||
|
蝦皮 SKU,价格是最近货运单台币售价、不是 PDD 人民币采购价;展示规格原文、订单
|
||||||
|
数、累计数量、最近图片、最后出现时间及唯一正式 SKU 匹配结果,多条匹配交给人工。
|
||||||
|
|||||||
Reference in New Issue
Block a user