feat: 重构顺运宝规格主链路 (#88)

This commit is contained in:
chengma
2026-08-10 12:24:23 +08:00
parent 5bb1258462
commit d1165b9e17
28 changed files with 985 additions and 531 deletions
+1 -1
View File
@@ -506,7 +506,7 @@ func CreatePddProduct(db *sql.DB, rawURL string) (string, error) {
// UpdatePddProductURL 保存弹窗里改过的链接。
//
// `[必须]` 新链接必须还是同一个商品。换成别的商品要新建一条,不能就地改:
// 这一行上挂着采集结果和 sku_mappings,goods_id 一换,
// 这一行上挂着采集结果和 spec_mappings,goods_id 一换,
// 那些数据就全指到错的商品上了,之后按它下单就是买错东西。
func UpdatePddProductURL(db *sql.DB, id int64, rawURL string) error {
goodsID, err := ParsePddGoodsID(rawURL)
+1 -1
View File
@@ -228,7 +228,7 @@ func TestUpdatePddProductURL_同一个商品可以改写法(t *testing.T) {
}
}
// 换成另一个商品必须拒绝:这一行上挂着采集结果和 sku_mappings,
// 换成另一个商品必须拒绝:这一行上挂着采集结果和 spec_mappings,
// goods_id 一换那些数据就全指到错的商品上,之后按它下单就是买错东西。
func TestUpdatePddProductURL_换成别的商品要拒绝(t *testing.T) {
db := newTestDB(t)
+25 -47
View File
@@ -198,49 +198,30 @@ func TestMarkCollecting_失败后可重新采集(t *testing.T) {
// ── 映射隔离:本工单的安全核心 ─────────────────────────
// insertShopeeSKU 造蝦皮商品和它的一个规格。
// sku_mappings 有外键指向 shopee_skus,映射相关的测试都要先造它。
func insertShopeeSKU(t *testing.T, db *sql.DB, goodsID, skuID string) {
t.Helper()
now := model.NowISO()
if _, err := db.Exec(`
INSERT INTO shopee_products (goods_id, title, created_at, updated_at)
VALUES (?, '测试商品', ?, ?)
ON DUPLICATE KEY UPDATE goods_id = VALUES(goods_id)`, goodsID, now, now); err != nil {
t.Fatalf("插入蝦皮商品失败: %v", err)
}
if _, err := db.Exec(`
INSERT INTO shopee_skus (sku_id, goods_id, spec_raw, created_at, updated_at)
VALUES (?, ?, '黑色,M', ?, ?)`, skuID, goodsID, now, now); err != nil {
t.Fatalf("插入蝦皮规格失败: %v", err)
}
}
// insertMapping 造一条规格映射。
func insertMapping(t *testing.T, db *sql.DB, shopeeSKU, pddGoodsID string, options map[string]string) {
func insertMapping(t *testing.T, db *sql.DB, goodsID, specKey, pddGoodsID string, options map[string]string) {
t.Helper()
key, err := OptionKey(options)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`
INSERT INTO sku_mappings (shopee_sku_id, pdd_goods_id, pdd_option_key,
pdd_options, goods_id, mapped_at)
VALUES (?, ?, ?, ?, 'SHOPEE-1', ?)`,
shopeeSKU, pddGoodsID, key, key, model.NowISO()); err != nil {
if err := repository.UpsertSpecMapping(db, model.SpecMapping{
ShopeeGoodsID: goodsID, SpecKey: specKey, SpecRaw: specKey,
PddGoodsID: pddGoodsID, PddOptionKey: key, PddOptions: key, MappedAt: model.NowISO(),
}); err != nil {
t.Fatalf("插入映射失败: %v", err)
}
}
// findMapping 模拟业务查询:**永远带上当前的 PDD 商品**。
// 这个条件就是隔离机制本身,少了它旧映射就会被误用。
func findMapping(t *testing.T, db *sql.DB, shopeeSKU, currentPddGoodsID string) (string, bool) {
func findMapping(t *testing.T, db *sql.DB, goodsID, specKey, currentPddGoodsID string) (string, bool) {
t.Helper()
var key string
err := db.QueryRow(`
SELECT pdd_option_key FROM sku_mappings
WHERE shopee_sku_id = ? AND pdd_goods_id = ?`,
shopeeSKU, currentPddGoodsID).Scan(&key)
SELECT pdd_option_key FROM spec_mappings
WHERE shopee_goods_id = ? AND spec_key = ? AND pdd_goods_id = ?`,
goodsID, specKey, currentPddGoodsID).Scan(&key)
if err == sql.ErrNoRows {
return "", false
}
@@ -254,21 +235,20 @@ func findMapping(t *testing.T, db *sql.DB, shopeeSKU, currentPddGoodsID string)
//
// 不隔离的话:B 恰好也有"黑色/M码"这个组合,但完全是另一件衣服,
// 就会**静默买错东西**,而且事后查不出来。
func TestSKUMapping_换PDD商品后查不到旧映射(t *testing.T) {
func TestSpecMapping_换PDD商品后查不到旧映射(t *testing.T) {
db := newTestDB(t)
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
// 蝦皮 SKU 原来匹配到 PDD 商品 A 的"黑色/M码"
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
// 顺运宝规格原来匹配到 PDD 商品 A 的"黑色/M码"
insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
if _, ok := findMapping(t, db, "SKU-1", "PDD-A"); !ok {
if _, ok := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A"); !ok {
t.Fatal("前置条件不对:A 的映射应该查得到")
}
// A 下架,换成 B。查 B 的映射应该查不到 —— 界面显示"待匹配"
if key, ok := findMapping(t, db, "SKU-1", "PDD-B"); ok {
if key, ok := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B"); ok {
t.Errorf("换成 B 之后不该查到映射,却拿到了 %q —— "+
"这正是会导致买错商品的情况", key)
}
@@ -277,18 +257,17 @@ func TestSKUMapping_换PDD商品后查不到旧映射(t *testing.T) {
// 换回原来的 PDD 商品,旧映射要还能用。
// 这是把 pdd_goods_id 放进主键(而不是换商品时删掉旧映射)换来的好处:
// PDD 商品补货换回去时,之前的人工匹配成果直接复用。
func TestSKUMapping_换回原商品旧映射仍可用(t *testing.T) {
func TestSpecMapping_换回原商品旧映射仍可用(t *testing.T) {
db := newTestDB(t)
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
// 换到 B,也匹配一次
insertMapping(t, db, "SKU-1", "PDD-B", map[string]string{"color": "黑色", "size": "M"})
insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B", map[string]string{"color": "黑色", "size": "M"})
// 换回 A
key, ok := findMapping(t, db, "SKU-1", "PDD-A")
key, ok := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A")
if !ok {
t.Fatal("换回 A 之后,A 的旧映射应该还在")
}
@@ -298,24 +277,23 @@ func TestSKUMapping_换回原商品旧映射仍可用(t *testing.T) {
}
}
// 同一个蝦皮 SKU 可以同时有多个 PDD 商品的映射,互不干扰。
func TestSKUMapping_同一SKU可对多个PDD商品各有映射(t *testing.T) {
// 同一蝦皮商品规格可以同时有多个 PDD 商品的映射,互不干扰。
func TestSpecMapping_同一商品规格可对多个PDD商品各有映射(t *testing.T) {
db := newTestDB(t)
insertShopeeSKU(t, db, "SHOPEE-1", "SKU-1")
repository.EnsurePddProduct(db, "PDD-A", "https://x/a")
repository.EnsurePddProduct(db, "PDD-B", "https://x/b")
insertMapping(t, db, "SKU-1", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
insertMapping(t, db, "SKU-1", "PDD-B", map[string]string{"color": "黑", "size": "均码"})
insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A", map[string]string{"color": "黑色", "size": "M码"})
insertMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B", map[string]string{"color": "黑", "size": "均码"})
var n int
db.QueryRow(`SELECT COUNT(*) FROM sku_mappings WHERE shopee_sku_id='SKU-1'`).Scan(&n)
db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='SHOPEE-1' AND spec_key='黑色,M'`).Scan(&n)
if n != 2 {
t.Errorf("应能同时存在 2 条映射,实际 %d 条", n)
}
kA, _ := findMapping(t, db, "SKU-1", "PDD-A")
kB, _ := findMapping(t, db, "SKU-1", "PDD-B")
kA, _ := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-A")
kB, _ := findMapping(t, db, "SHOPEE-1", "黑色,M", "PDD-B")
if kA == kB {
t.Error("两个 PDD 商品的映射不该相同")
}
+15 -10
View File
@@ -8,6 +8,7 @@ import (
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/spec"
)
// PddOptionChoice 是采集结果中的一个可购买规格组合。
@@ -75,7 +76,7 @@ func findPddChoice(raw, key string) (*PddOptionChoice, error) {
return nil, nil
}
// SaveSybMapping 保存顺运宝明细对应蝦皮 SKU 到当前 PDD 商品的可复用映射。
// SaveSybMapping 保存顺运宝商品规格到当前 PDD 商品的可复用映射。
func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error {
tx, err := db.Begin()
if err != nil {
@@ -89,8 +90,12 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error {
if context == nil {
return fmt.Errorf("顺运宝明细不存在")
}
if context.Order.ShopeeSKUID == "" {
return fmt.Errorf("请先确认蝦皮规格")
key, err := spec.SpecKey(context.Order.ProductSpec)
if err != nil || context.Order.SpecKey == "" {
return fmt.Errorf("顺运宝未提供有效规格,不能保存映射")
}
if key != context.Order.SpecKey {
return fmt.Errorf("顺运宝规格身份键不一致,请联系维护者检查数据")
}
if context.PddGoodsID == "" || context.PddCollectStatus != string(model.CollectCollected) ||
strings.TrimSpace(context.PddSkusJSON) == "" {
@@ -103,11 +108,11 @@ func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string) error {
if choice == nil {
return fmt.Errorf("所选 PDD 规格已不存在或不可购买,请刷新后重试")
}
if err := repository.UpsertSKUMapping(tx, model.SKUMapping{
ShopeeSKUID: context.Order.ShopeeSKUID, PddGoodsID: context.PddGoodsID,
if err := repository.UpsertSpecMapping(tx, model.SpecMapping{
ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key,
SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID,
PddOptionKey: choice.Key, PddOptions: choice.OptionsJSON,
GoodsID: context.Order.ShopeeGoodsID, MappedAt: model.NowISO(),
MappedBy: strings.TrimSpace(operator),
MappedAt: model.NowISO(), MappedBy: strings.TrimSpace(operator),
}); err != nil {
return err
}
@@ -179,7 +184,7 @@ func CreatePurchaseTasks(db *sql.DB, actor *model.User, requests []PurchaseTaskR
if err := repository.InsertPurchaseTask(tx, model.Task{
TaskID: newPurchaseTaskID(), AssignedClient: clientID,
SybID: context.Order.SybID, OrderNo: context.Order.OrderNo,
GoodsID: context.Order.ShopeeGoodsID, ShopeeSKUID: context.Order.ShopeeSKUID,
GoodsID: context.Order.ShopeeGoodsID,
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
PddOptions: optionsJSON, Quantity: context.Order.Quantity,
MaxPriceCent: request.MaxPriceCent,
@@ -202,8 +207,8 @@ func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) (
if context == nil {
return "顺运宝明细不存在", nil, "", nil
}
if context.Order.ShopeeSKUID == "" {
return "蝦皮规格未确认", context, "", nil
if context.Order.SpecKey == "" {
return "顺运宝未提供规格", context, "", nil
}
if context.PddGoodsID == "" || context.PddGoodsURL == "" {
return "未关联 PDD 商品", context, "", nil
+37 -8
View File
@@ -22,18 +22,11 @@ const collectedThreeDimensions = `{
func seedPurchasableWorkflow(t *testing.T, db *sql.DB, sybID string) string {
t.Helper()
if err := repository.UpsertShopeeProduct(db, "SP-1", "测试上衣", "正常", ""); err != nil {
t.Fatal(err)
}
seedWorkflowSKU(t, db, "SKU-1", "SP-1", "黑色,M")
order := seedWorkflowOrder(t, db, sybID, "SP-1", "黑色,M")
order.PriceTwdCent = 999900
if _, err := repository.UpsertSybOrder(db, order); err != nil {
t.Fatal(err)
}
if err := AssignSybShopeeSKU(db, sybID, "SKU-1"); err != nil {
t.Fatal(err)
}
if _, err := AssociateShopeePdd(db, "SP-1", pddURLA, false); err != nil {
t.Fatal(err)
}
@@ -62,7 +55,7 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
t.Fatal(err)
}
if !detail.MappingValid {
t.Fatal("保存后相同蝦皮 SKU + 当前 PDD 商品应自动复用")
t.Fatal("保存后相同蝦皮商品规格 + 当前 PDD 商品应自动复用")
}
list, err := ListSybOrdersView(db, "", SybStagePurchaseReady, 1)
if err != nil {
@@ -73,6 +66,35 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
}
}
func TestSybMapping_同商品同规格第二条明细自动命中(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-1")
if err := SaveSybMapping(db, "SYB-1", key, "USR-1"); err != nil {
t.Fatal(err)
}
seedWorkflowOrder(t, db, "SYB-2", "SP-1", " 黑色,M ")
detail, err := GetSybProcessingDetail(db, "SYB-2")
if err != nil || detail == nil || !detail.MappingValid || detail.Stage != SybStagePurchaseReady {
t.Fatalf("第二条相同规格应自动命中: detail=%+v err=%v", detail, err)
}
}
func TestSybMapping_解析失败原文仍可保存且空规格被阻断(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-BASE")
seedWorkflowOrder(t, db, "SYB-DIRTY", "SP-1", "紅色,3XL建議80-90公斤】")
if err := SaveSybMapping(db, "SYB-DIRTY", key, "USR-1"); err != nil {
t.Fatalf("规格解析失败不应影响身份键映射: %v", err)
}
seedWorkflowOrder(t, db, "SYB-EMPTY", "SP-1", "")
if err := SaveSybMapping(db, "SYB-EMPTY", key, "USR-1"); err == nil {
t.Fatal("空规格不得保存映射")
}
if _, err := AssociateSybPdd(db, "SYB-EMPTY", pddURLA, false); err == nil {
t.Fatal("空规格不得进入采购关联流程")
}
}
func TestSybMapping_换品和选项消失都会失效(t *testing.T) {
t.Run("换 PDD 商品隔离旧映射", func(t *testing.T) {
db := newTestDB(t)
@@ -86,6 +108,13 @@ func TestSybMapping_换品和选项消失都会失效(t *testing.T) {
if detail.MappingValid {
t.Fatal("更换 PDD 商品后不得复用旧映射")
}
if _, err := AssociateShopeePdd(db, "SP-1", pddURLA, true); err != nil {
t.Fatal(err)
}
detail, _ = GetSybProcessingDetail(db, "SYB-1")
if !detail.MappingValid {
t.Fatal("换回原 PDD 商品后应复用原映射")
}
})
t.Run("最新采集结果删除原选项", func(t *testing.T) {
+9
View File
@@ -31,6 +31,7 @@ type ShopeeProductView struct {
// `[必须]` > 0 时整行标黄(沿用 .row-warn)——聚合成商品级之后,
// "部分失败"的商品数字看起来完全正常,坏数据会被吃掉,见 #41。
PendingCount int
SourceText string
// PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红——
// 这是最需要操作员注意的状态,见 §4.2。
@@ -108,6 +109,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
SizeCount: r.SizeCount,
SKUCount: r.SKUCount,
PendingCount: r.PendingCount,
SourceText: shopeeSourceText(r.Source),
UpdatedAt: formatLocalTime(r.UpdatedAt),
}
@@ -131,6 +133,13 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
return result, nil
}
func shopeeSourceText(source string) string {
if source == "syb" {
return "顺运宝骨架"
}
return "蝦皮报表"
}
// ---------- 状态筛选 ----------
// shopeeStatusTexts 是状态筛选下拉框每个取值对应的中文文字,
+5 -2
View File
@@ -75,8 +75,11 @@ func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (str
if context == nil {
return "", fmt.Errorf("顺运宝明细不存在")
}
if context.Order.ShopeeSKUID == "" {
return "", fmt.Errorf("请先确认蝦皮规格")
if context.Order.ShopeeGoodsID == "" {
return "", fmt.Errorf("顺运宝未提供蝦皮商品 ID")
}
if context.Order.SpecKey == "" {
return "", fmt.Errorf("顺运宝未提供规格,不能进入采购流程")
}
return AssociateShopeePdd(db, context.Order.ShopeeGoodsID, rawURL, confirmReplace)
}
+16 -133
View File
@@ -5,9 +5,7 @@
// 那些分别在 handler/web/others.go 和 repository/syb.go。
//
// 接口契约见 docs/admin/08-顺运宝接口.md,工单见 #46。三条最容易出事的规则:
// 1. shopee_sku_id 绝不能被同步写入/覆盖——这条已经在
// repository.UpsertSybOrder 的 SQL 层面保证,本文件不需要、
// 也不允许再传一份"新的" shopee_sku_id 进去。
// 1. shopee_sku_id 是历史兼容列,同步不读写;规格身份由 product_spec/spec_key 表达。
// 2. 增量必须从"上次同步日期当天"重新拉,不是第二天,见 syncDateRange。
// 3. 中途失败不更新 last_synced_at,见 RunSybSync 的最后一步。
package service
@@ -772,21 +770,16 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
Title: item.ProductTitle,
ProductSpec: item.ProductSpec,
ShopeeGoodsID: strconv.FormatInt(item.ProductID, 10),
// `[必须]` 不设置 ShopeeSKUID——顺运宝没有这个值,
// repository.UpsertSybOrder 也不会用它覆盖已有的匹配结果。
Quantity: item.ProductQty,
PriceTwdCent: roundYuanToCent(item.ProductPrice),
ImageURL: imageURLFromThumb(baseURL, item.ProductThumb),
SybData: sybData,
Quantity: item.ProductQty,
PriceTwdCent: roundYuanToCent(item.ProductPrice),
ImageURL: imageURLFromThumb(baseURL, item.ProductThumb),
SybData: sybData,
}
created, err := repository.UpsertSybOrder(tx, order)
if err != nil {
return err
}
if _, err := AutoResolveSybShopeeSKU(tx, order); err != nil {
return err
}
if created {
report.Created++
} else {
@@ -921,7 +914,6 @@ type SybOrderView struct {
Title string
ProductSpec string
ShopeeGoodsID string
ShopeeSKUID string
Quantity int
PriceText string // "NT$239.00",和人民币价格一眼分得清
ImageURL string
@@ -936,8 +928,7 @@ type SybOrderView struct {
}
const (
SybStageMissingShopee = "missing_shopee"
SybStageSKUPending = "sku_pending"
SybStageSpecMissing = "spec_missing"
SybStagePddMissing = "pdd_missing"
SybStagePddPending = "pdd_pending"
SybStagePddCollecting = "pdd_collecting"
@@ -957,8 +948,7 @@ type SybStageOption struct {
func SybStageOptions() []SybStageOption {
return []SybStageOption{
{Value: "", Text: "全部"},
{Value: SybStageMissingShopee, Text: "未找到蝦皮商品"},
{Value: SybStageSKUPending, Text: "待确认蝦皮规格"},
{Value: SybStageSpecMissing, Text: "采购数据异常:顺运宝未提供规格"},
{Value: SybStagePddMissing, Text: "未关联 PDD"},
{Value: SybStagePddPending, Text: "PDD 待采集"},
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
@@ -990,12 +980,10 @@ func SybStageLabel(stage string) string {
func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string) {
switch {
case !c.ShopeeExists:
return SybStageMissingShopee, "未找到蝦皮商品", "请先导入或补充该蝦皮商品。", "查看"
case c.Order.ShopeeSKUID == "":
return SybStageSKUPending, "待确认蝦皮规格", "请选择与顺运宝规格原文对应的蝦皮 SKU。", "确认规格"
case c.Order.SpecKey == "":
return SybStageSpecMissing, "采购数据异常:顺运宝未提供规格", "缺少规格原文,不能匹配或创建采购任务。", "核对数据"
case c.PddGoodsID == "" || c.PddCollectStatus == "":
return SybStagePddMissing, "未关联 PDD", "蝦皮规格已确认,下一步关联采购商品。", "关联 PDD"
return SybStagePddMissing, "未关联 PDD", "下一步关联采购商品。", "关联 PDD"
case c.PddCollectStatus == string(model.CollectPending):
return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集"
case c.PddCollectStatus == string(model.CollectCollecting):
@@ -1030,9 +1018,8 @@ type SybListResult struct {
// ListSybOrdersView 按筛选条件分页查货运单明细列表,翻成界面文字。
//
// `[必须]` 处理阶段由顺运宝明细、蝦皮 SKU、PDD 关联和采集状态实时推导,
// 不额外保存一份容易过期的状态字段。shopee_sku_id 只表示已经确认了
// 顺运宝规格对应的蝦皮 SKU,不代表已经完成 PDD 规格映射。
// `[必须]` 处理阶段由顺运宝规格键、PDD 关联、采集状态和规格映射实时推导,
// 不额外保存一份容易过期的状态字段。
func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
stage = ParseSybStage(stage)
if stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
@@ -1074,7 +1061,7 @@ func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListRes
}
// listAdvancedSybStage 在 Service 解析最新 skus_json 后筛选映射相关阶段。
// 这三个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
// 这四个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
// OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表
// 和其余阶段仍在数据库分页。
func listAdvancedSybStage(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
@@ -1118,7 +1105,7 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
o := context.Order
v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title,
ProductSpec: o.ProductSpec, ShopeeGoodsID: o.ShopeeGoodsID,
ShopeeSKUID: o.ShopeeSKUID, Quantity: o.Quantity, ImageURL: o.ImageURL,
Quantity: o.Quantity, ImageURL: o.ImageURL,
UpdatedAt: formatLocalTime(o.UpdatedAt)}
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
v.CanPurchase = v.Stage == SybStagePurchaseReady
@@ -1149,89 +1136,6 @@ func defaultMaxPrice(context repository.SybOrderContext) string {
return fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
}
// AutoResolveSybShopeeSKU 只接受确定性唯一候选:先比规格原文,再比解析后的
// 颜色和尺码。零候选或多候选都不写库,交给采购员确认。
func AutoResolveSybShopeeSKU(q repository.Execer, order model.SybOrder) (string, error) {
if order.ShopeeGoodsID == "" || order.ProductSpec == "" {
return "", nil
}
skus, err := repository.ListShopeeSKUsByGoodsID(q, order.ShopeeGoodsID)
if err != nil {
return "", err
}
var exact []model.ShopeeSKU
wantRaw := strings.TrimSpace(order.ProductSpec)
for _, sku := range skus {
if strings.TrimSpace(sku.SpecRaw) == wantRaw {
exact = append(exact, sku)
}
}
candidate := uniqueShopeeSKU(exact)
if candidate == nil {
color, size, _, ok := ParseSpec(order.ProductSpec)
if ok {
var parsed []model.ShopeeSKU
for _, sku := range skus {
if sku.ParseOK && strings.TrimSpace(sku.Color) == strings.TrimSpace(color) &&
strings.TrimSpace(sku.Size) == strings.TrimSpace(size) {
parsed = append(parsed, sku)
}
}
candidate = uniqueShopeeSKU(parsed)
}
}
if candidate == nil {
return "", nil
}
written, err := repository.SetSybShopeeSKUIfEmpty(q, order.SybID, candidate.SKUID)
if err != nil || !written {
return "", err
}
return candidate.SKUID, nil
}
func uniqueShopeeSKU(candidates []model.ShopeeSKU) *model.ShopeeSKU {
if len(candidates) != 1 {
return nil
}
return &candidates[0]
}
// AssignSybShopeeSKU 保存人工选择,并在事务内验证 SKU 确实属于该蝦皮商品。
func AssignSybShopeeSKU(db *sql.DB, sybID, skuID string) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开始确认蝦皮规格事务失败: %w", err)
}
defer tx.Rollback()
context, err := repository.GetSybOrderContext(tx, strings.TrimSpace(sybID))
if err != nil {
return err
}
if context == nil {
return fmt.Errorf("顺运宝明细不存在")
}
sku, err := repository.GetShopeeSKUByID(tx, strings.TrimSpace(skuID))
if err != nil {
return err
}
if sku == nil || sku.GoodsID != context.Order.ShopeeGoodsID {
return fmt.Errorf("所选蝦皮规格不属于该商品,请刷新后重试")
}
if err := repository.SetSybShopeeSKU(tx, context.Order.SybID, sku.SKUID); err != nil {
return err
}
return tx.Commit()
}
type SybSKUCandidateView struct {
SKUID string
SpecRaw string
Color string
Size string
Selected bool
}
// SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。
type SybProcessingDetail struct {
SybID string
@@ -1239,12 +1143,10 @@ type SybProcessingDetail struct {
Title string
ProductSpec string
ShopeeGoodsID string
ShopeeSKUID string
Quantity int
Stage string
StageText string
StageHelp string
Candidates []SybSKUCandidateView
ShopeeExists bool
PddGoodsID string
PddURL string
@@ -1265,8 +1167,8 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
o := context.Order
d := &SybProcessingDetail{
SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title, ProductSpec: o.ProductSpec,
ShopeeGoodsID: o.ShopeeGoodsID, ShopeeSKUID: o.ShopeeSKUID,
Quantity: o.Quantity, ShopeeExists: context.ShopeeExists,
ShopeeGoodsID: o.ShopeeGoodsID,
Quantity: o.Quantity, ShopeeExists: context.ShopeeExists,
PddGoodsID: context.PddGoodsID, PddURL: context.PddGoodsURL,
CollectStatus: context.PddCollectStatus, CollectMsg: context.PddCollectMsg,
}
@@ -1288,25 +1190,6 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
}
}
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
if context.ShopeeExists {
skus, err := repository.ListShopeeSKUsByGoodsID(db, o.ShopeeGoodsID)
if err != nil {
return nil, err
}
for _, sku := range skus {
color, size := sku.Color, sku.Size
if color == "" {
color = placeholder
}
if size == "" {
size = placeholder
}
d.Candidates = append(d.Candidates, SybSKUCandidateView{
SKUID: sku.SKUID, SpecRaw: sku.SpecRaw, Color: color, Size: size,
Selected: sku.SKUID == o.ShopeeSKUID,
})
}
}
return d, nil
}
+67 -92
View File
@@ -8,11 +8,11 @@ import (
"cmautobuy/admin/repository"
)
func seedWorkflowOrder(t *testing.T, db *sql.DB, sybID, goodsID, spec string) model.SybOrder {
func seedWorkflowOrder(t *testing.T, db *sql.DB, sybID, goodsID, rawSpec string) model.SybOrder {
t.Helper()
order := model.SybOrder{
SybID: sybID, OrderNo: "ORDER-" + sybID, Title: "测试商品",
ProductSpec: spec, ShopeeGoodsID: goodsID, Quantity: 1, SybData: `{}`,
ProductSpec: rawSpec, ShopeeGoodsID: goodsID, Quantity: 1, SybData: `{}`,
}
if _, err := repository.UpsertSybOrder(db, order); err != nil {
t.Fatalf("准备顺运宝明细失败: %v", err)
@@ -20,106 +20,41 @@ func seedWorkflowOrder(t *testing.T, db *sql.DB, sybID, goodsID, spec string) mo
return order
}
func seedWorkflowSKU(t *testing.T, db *sql.DB, skuID, goodsID, spec string) {
t.Helper()
color, size, advice, ok := ParseSpec(spec)
if err := repository.UpsertShopeeSKU(db, skuID, goodsID, spec, color, size, advice, ok, ""); err != nil {
t.Fatalf("准备蝦皮规格失败: %v", err)
}
}
func readOrderSKU(t *testing.T, db *sql.DB, sybID string) string {
t.Helper()
var sku sql.NullString
if err := db.QueryRow(`SELECT shopee_sku_id FROM syb_orders WHERE syb_id = ?`, sybID).Scan(&sku); err != nil {
t.Fatal(err)
}
return sku.String
}
func TestAutoResolveSybShopeeSKU_只采用唯一精确候选(t *testing.T) {
t.Run("规格原文唯一一致", func(t *testing.T) {
db := newSyncTestDB(t)
if err := repository.UpsertShopeeProduct(db, "SP-1", "商品", "", ""); err != nil {
t.Fatal(err)
}
seedWorkflowSKU(t, db, "SKU-1", "SP-1", "黑色,M")
order := seedWorkflowOrder(t, db, "SYB-1", "SP-1", "黑色,M")
got, err := AutoResolveSybShopeeSKU(db, order)
if err != nil || got != "SKU-1" || readOrderSKU(t, db, "SYB-1") != "SKU-1" {
t.Fatalf("应自动确认唯一原文候选: got=%q stored=%q err=%v", got, readOrderSKU(t, db, "SYB-1"), err)
}
})
t.Run("解析后颜色尺码唯一一致", func(t *testing.T) {
db := newSyncTestDB(t)
if err := repository.UpsertShopeeProduct(db, "SP-2", "商品", "", ""); err != nil {
t.Fatal(err)
}
seedWorkflowSKU(t, db, "SKU-2", "SP-2", "黑色,M【建議40-50公斤】")
order := seedWorkflowOrder(t, db, "SYB-2", "SP-2", "黑色,M")
got, err := AutoResolveSybShopeeSKU(db, order)
if err != nil || got != "SKU-2" {
t.Fatalf("应采用唯一颜色尺码候选: got=%q err=%v", got, err)
}
})
t.Run("多个颜色尺码候选不猜测", func(t *testing.T) {
db := newSyncTestDB(t)
if err := repository.UpsertShopeeProduct(db, "SP-3", "商品", "", ""); err != nil {
t.Fatal(err)
}
seedWorkflowSKU(t, db, "SKU-3A", "SP-3", "黑色,M【建議40-50公斤】")
seedWorkflowSKU(t, db, "SKU-3B", "SP-3", "黑色,M【建議50-60公斤】")
order := seedWorkflowOrder(t, db, "SYB-3", "SP-3", "黑色,M")
got, err := AutoResolveSybShopeeSKU(db, order)
if err != nil || got != "" || readOrderSKU(t, db, "SYB-3") != "" {
t.Fatalf("多候选必须留给人工确认: got=%q stored=%q err=%v", got, readOrderSKU(t, db, "SYB-3"), err)
}
})
}
func TestAssignSybShopeeSKU_拒绝其他商品的规格(t *testing.T) {
func TestUpsertSybOrder_补建骨架并由报表提升来源(t *testing.T) {
db := newSyncTestDB(t)
for _, goodsID := range []string{"SP-A", "SP-B"} {
if err := repository.UpsertShopeeProduct(db, goodsID, goodsID, "", ""); err != nil {
t.Fatal(err)
}
}
seedWorkflowSKU(t, db, "SKU-A", "SP-A", "黑色,M")
seedWorkflowSKU(t, db, "SKU-B", "SP-B", "白色,L")
seedWorkflowOrder(t, db, "SYB-A", "SP-A", "未知规格")
seedWorkflowOrder(t, db, "SYB-1", "SP-1", "黑色, M")
if err := AssignSybShopeeSKU(db, "SYB-A", "SKU-B"); err == nil {
t.Fatal("不得把其他蝦皮商品的 SKU 保存到当前顺运宝明细")
product, err := repository.GetShopeeProductByGoodsID(db, "SP-1")
if err != nil || product == nil || product.Source != "syb" || product.Title != "测试商品" {
t.Fatalf("顺运宝骨架不正确: product=%+v err=%v", product, err)
}
if got := readOrderSKU(t, db, "SYB-A"); got != "" {
t.Fatalf("越权选择后不应写库,实际 %q", got)
context, err := repository.GetSybOrderContext(db, "SYB-1")
if err != nil || context == nil || context.Order.SpecKey != "黑色, M" {
t.Fatalf("规格身份键不正确: context=%+v err=%v", context, err)
}
if err := AssignSybShopeeSKU(db, "SYB-A", "SKU-A"); err != nil {
t.Fatalf("同商品 SKU 应保存成功: %v", err)
if err := repository.UpsertShopeeProduct(db, "SP-1", "报表标题", "正常", "MAIN-1"); err != nil {
t.Fatal(err)
}
product, err = repository.GetShopeeProductByGoodsID(db, "SP-1")
if err != nil || product.Source != "report" || product.Title != "报表标题" {
t.Fatalf("报表导入应提升骨架来源: product=%+v err=%v", product, err)
}
seedWorkflowOrder(t, db, "SYB-2", "SP-1", "白色,L")
product, _ = repository.GetShopeeProductByGoodsID(db, "SP-1")
if product.Source != "report" || product.Title != "报表标题" {
t.Fatalf("后续同步不得覆盖报表商品: %+v", product)
}
}
func TestListSybOrdersView_阶段筛选区分蝦皮识别和PDD准备(t *testing.T) {
func TestListSybOrdersView_空规格阻断且普通规格直接进入PDD阶段(t *testing.T) {
db := newSyncTestDB(t)
seedWorkflowOrder(t, db, "SYB-MISSING", "NOT-IMPORTED", "黑色,M")
if err := repository.UpsertShopeeProduct(db, "SP-READY", "商品", "", ""); err != nil {
t.Fatal(err)
}
seedWorkflowSKU(t, db, "SKU-READY", "SP-READY", "黑色,M")
seedWorkflowOrder(t, db, "SYB-SKU", "SP-READY", "无法自动匹配")
order := seedWorkflowOrder(t, db, "SYB-PDD", "SP-READY", "黑色,M")
if _, err := AutoResolveSybShopeeSKU(db, order); err != nil {
t.Fatal(err)
}
seedWorkflowOrder(t, db, "SYB-EMPTY", "SP-EMPTY", "")
seedWorkflowOrder(t, db, "SYB-PDD", "SP-PDD", "黑色,M")
cases := []struct{ stage, wantID string }{
{SybStageMissingShopee, "SYB-MISSING"},
{SybStageSKUPending, "SYB-SKU"},
{SybStageSpecMissing, "SYB-EMPTY"},
{SybStagePddMissing, "SYB-PDD"},
}
for _, tc := range cases {
@@ -135,3 +70,43 @@ func TestListSybOrdersView_阶段筛选区分蝦皮识别和PDD准备(t *testing
}
}
}
func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) {
key, err := OptionKey(map[string]string{"color": "黑色", "size": "M码", "style": "常规"})
if err != nil {
t.Fatal(err)
}
ready := repository.SybOrderContext{
Order: model.SybOrder{SpecKey: "黑色,M", Quantity: 1},
PddGoodsID: "737116531267", PddCollectStatus: string(model.CollectCollected),
PddSkusJSON: collectedThreeDimensions, MappingOptionKey: key,
}
copyWith := func(change func(*repository.SybOrderContext)) repository.SybOrderContext {
value := ready
change(&value)
return value
}
cases := []struct {
want string
context repository.SybOrderContext
}{
{SybStageSpecMissing, copyWith(func(c *repository.SybOrderContext) { c.Order.SpecKey = "" })},
{SybStagePddMissing, copyWith(func(c *repository.SybOrderContext) { c.PddGoodsID, c.PddCollectStatus = "", "" })},
{SybStagePddPending, copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectPending) })},
{SybStagePddCollecting, copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectCollecting) })},
{SybStagePddFailed, copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectFailed) })},
{SybStageMappingPending, copyWith(func(c *repository.SybOrderContext) { c.MappingOptionKey = "" })},
{SybStageTaskCreated, copyWith(func(c *repository.SybOrderContext) { c.HasActiveTask = true })},
{SybStagePurchaseBlocked, copyWith(func(c *repository.SybOrderContext) { c.Order.Quantity = 0 })},
{SybStagePurchaseReady, ready},
}
if got := len(SybStageOptions()) - 1; got != len(cases) {
t.Fatalf("筛选项有 %d 个业务阶段,但测试只覆盖 %d 个", got, len(cases))
}
for _, tc := range cases {
stage, text, _, action := sybStageFor(tc.context)
if stage != tc.want || text != SybStageLabel(tc.want) || action == "" {
t.Fatalf("阶段 %s 推导错误: stage=%s text=%q action=%q", tc.want, stage, text, action)
}
}
}