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
+42 -64
View File
@@ -11,6 +11,7 @@ import (
"strings"
"cmautobuy/admin/model"
"cmautobuy/admin/spec"
)
// ---------- 会话缓存 ----------
@@ -224,11 +225,8 @@ func CountSybSyncRuns(q Execer) (int, error) {
// UpsertSybOrder 写入或更新一条顺运宝货运单明细行。
//
// `[必须]` DO UPDATE SET 里绝不允许出现 shopee_sku_id。它是规格匹配的
// 结果(人工确认或自动匹配产生),顺运宝那边根本没有这个值——写进去
// 就是写 NULL,把人工攒的匹配成果洗掉,而且不报错。这和 #38 里
// pdd_goods_url 不能被 Excel 导入覆盖是同一类问题,见工单 #46、
// docs/admin/08-顺运宝接口.md §6.2。
// `[必须]` shopee_sku_id 已退出采购主链路,INSERT 和 UPDATE 都不再写它。
// 旧列只为只追加迁移兼容保留。
//
// `[必须]` shopee_goods_id **可以**被覆盖——它就是顺运宝
// detail.productId,来自顺运宝,不是人工填的。
@@ -238,6 +236,14 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
if o.SybID == "" {
return false, fmt.Errorf("syb_id 不能为空")
}
var specKey any
if strings.TrimSpace(o.ProductSpec) != "" {
key, keyErr := spec.SpecKey(o.ProductSpec)
if keyErr != nil {
return false, fmt.Errorf("顺运宝明细 %s 的规格不能生成身份键: %w", o.SybID, keyErr)
}
specKey = key
}
var exists int
err = q.QueryRow(`SELECT 1 FROM syb_orders WHERE syb_id = ?`, o.SybID).Scan(&exists)
@@ -249,26 +255,33 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) {
}
now := model.NowISO()
if strings.TrimSpace(o.ShopeeGoodsID) != "" {
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)`,
o.ShopeeGoodsID, o.Title, now, now); err != nil {
return false, fmt.Errorf("为顺运宝明细 %s 补建蝦皮商品骨架失败: %w", o.SybID, err)
}
}
_, err = q.Exec(`
INSERT INTO syb_orders
(syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id,
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
order_no = VALUES(order_no),
title = VALUES(title),
product_spec = VALUES(product_spec),
spec_key = VALUES(spec_key),
shopee_goods_id = VALUES(shopee_goods_id),
quantity = VALUES(quantity),
price_twd_cent = VALUES(price_twd_cent),
image_url = VALUES(image_url),
syb_data = VALUES(syb_data),
updated_at = VALUES(updated_at)`,
// 注意:shopee_sku_id 只出现在 INSERT 的列清单里(新建行时写 o.ShopeeSKUID,
// 同步永远传空字符串),完全不出现在 DO UPDATE SET 里——已存在的行
// 这一列不受本语句影响,见上面的函数注释。
o.SybID, o.OrderNo, o.Title, nullableText(o.ProductSpec), nullableText(o.ShopeeGoodsID),
nullableText(o.ShopeeSKUID), o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL),
o.SybID, o.OrderNo, o.Title, nullableText(o.ProductSpec), specKey, nullableText(o.ShopeeGoodsID),
o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL),
o.SybData, now, now)
if err != nil {
return false, fmt.Errorf("写入顺运宝货运单明细 %s 失败: %w", o.SybID, err)
@@ -301,14 +314,12 @@ func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
args = append(args, like, like)
}
switch filter.Stage {
case "missing_shopee":
clauses = append(clauses, `sp.goods_id IS NULL`)
case "sku_pending":
clauses = append(clauses, `sp.goods_id IS NOT NULL AND so.shopee_sku_id IS NULL`)
case "spec_missing":
clauses = append(clauses, `so.spec_key IS NULL`)
case "pdd_missing":
clauses = append(clauses, `so.shopee_sku_id IS NOT NULL AND (sp.pdd_goods_id IS NULL OR sp.pdd_goods_id = '' OR pp.goods_id IS NULL)`)
clauses = append(clauses, `so.spec_key IS NOT NULL AND (sp.pdd_goods_id IS NULL OR sp.pdd_goods_id = '' OR pp.goods_id IS NULL)`)
case "pdd_pending", "pdd_collecting", "pdd_failed", "pdd_collected":
clauses = append(clauses, `so.shopee_sku_id IS NOT NULL AND pp.collect_status = ?`)
clauses = append(clauses, `so.spec_key IS NOT NULL AND pp.collect_status = ?`)
args = append(args, strings.TrimPrefix(filter.Stage, "pdd_"))
}
if len(clauses) == 0 {
@@ -321,8 +332,9 @@ const sybOrderContextFrom = `
FROM syb_orders so
LEFT JOIN shopee_products sp ON sp.goods_id = so.shopee_goods_id
LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL
LEFT JOIN sku_mappings sm
ON sm.shopee_sku_id = so.shopee_sku_id AND sm.pdd_goods_id = sp.pdd_goods_id`
LEFT JOIN spec_mappings sm
ON sm.shopee_goods_id = so.shopee_goods_id
AND sm.spec_key = so.spec_key AND sm.pdd_goods_id = sp.pdd_goods_id`
// SybOrderContext 是顺运宝明细及其当前蝦皮/PDD 处理上下文。
// 处理阶段由 service 计算,Repository 只提供数据库事实。
@@ -341,14 +353,14 @@ type SybOrderContext struct {
func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
var c SybOrderContext
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
var priceCent sql.NullInt64
var shopeeExists int
var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON sql.NullString
var mappingKey, mappingOptions sql.NullString
var hasActiveTask int
err := s.Scan(
&c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
&c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID,
&c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData,
&c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists,
&pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON,
@@ -356,8 +368,8 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
)
c.Order.Title = title.String
c.Order.ProductSpec = productSpec.String
c.Order.SpecKey = specKey.String
c.Order.ShopeeGoodsID = shopeeGoodsID.String
c.Order.ShopeeSKUID = shopeeSKUID.String
c.Order.PriceTwdCent = priceCent.Int64
c.Order.ImageURL = imageURL.String
c.ShopeeExists = shopeeExists != 0
@@ -376,8 +388,8 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) {
func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([]SybOrderContext, error) {
where, args := sybOrderFilterClause(filter)
query := `
SELECT so.syb_id, so.order_no, so.title, so.product_spec,
so.shopee_goods_id, so.shopee_sku_id, so.quantity,
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key,
so.shopee_goods_id, so.quantity,
so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at,
CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END,
sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg,
@@ -410,8 +422,8 @@ func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([
// GetSybOrderContext 按明细 ID 读取一条处理上下文。
func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
row, err := scanSybOrderContext(q.QueryRow(`
SELECT so.syb_id, so.order_no, so.title, so.product_spec,
so.shopee_goods_id, so.shopee_sku_id, so.quantity,
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key,
so.shopee_goods_id, so.quantity,
so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at,
CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END,
sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg,
@@ -429,45 +441,11 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) {
return &row, nil
}
// SetSybShopeeSKUIfEmpty 只为尚未确认规格的明细写入自动识别结果。
func SetSybShopeeSKUIfEmpty(q Execer, sybID, skuID string) (bool, error) {
result, err := q.Exec(`
UPDATE syb_orders
SET shopee_sku_id = ?, updated_at = ?
WHERE syb_id = ? AND shopee_sku_id IS NULL
AND EXISTS (
SELECT 1 FROM shopee_skus sk
WHERE sk.sku_id = ? AND sk.goods_id = syb_orders.shopee_goods_id
)`, skuID, model.NowISO(), sybID, skuID)
if err != nil {
return false, fmt.Errorf("自动确认顺运宝明细 %s 的蝦皮规格失败: %w", sybID, err)
}
n, err := result.RowsAffected()
return n > 0, err
}
// SetSybShopeeSKU 在调用方完成商品归属校验后保存人工选择。
func SetSybShopeeSKU(q Execer, sybID, skuID string) error {
result, err := q.Exec(`UPDATE syb_orders SET shopee_sku_id = ?, updated_at = ? WHERE syb_id = ?`,
skuID, model.NowISO(), sybID)
if err != nil {
return fmt.Errorf("保存顺运宝明细 %s 的蝦皮规格失败: %w", sybID, err)
}
n, err := result.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("顺运宝明细 %s 不存在", sybID)
}
return nil
}
// ListSybOrders 按筛选条件分页查货运单明细列表,按更新时间倒序。
func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) {
where, args := sybOrderFilterClause(filter)
sqlText := `
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.shopee_goods_id, so.shopee_sku_id,
SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key, so.shopee_goods_id,
so.quantity, so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at` +
sybOrderContextFrom + where + `
ORDER BY so.updated_at DESC, so.syb_id DESC LIMIT ? OFFSET ?`
@@ -482,18 +460,18 @@ func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.
var list []model.SybOrder
for rows.Next() {
var o model.SybOrder
var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString
var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString
var priceCent sql.NullInt64
if err := rows.Scan(
&o.SybID, &o.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID,
&o.SybID, &o.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID,
&o.Quantity, &priceCent, &imageURL, &o.SybData, &o.CreatedAt, &o.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("读取顺运宝货运单列表失败: %w", err)
}
o.Title = title.String
o.ProductSpec = productSpec.String
o.SpecKey = specKey.String
o.ShopeeGoodsID = shopeeGoodsID.String
o.ShopeeSKUID = shopeeSKUID.String
o.PriceTwdCent = priceCent.Int64
o.ImageURL = imageURL.String
list = append(list, o)