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
+5 -17
View File
@@ -23,8 +23,7 @@ import (
// ---------- 2. 顺运宝数据 ----------
// SybList 渲染货运单列表页。
// 「匹配状态」是算出来的(shopee_sku_id 是否非空),不是存的字段,
// 本工单(#46)不做规格匹配,这里只如实显示"有没有"。
// 「匹配状态」是根据 spec_mappings 和当前 PDD 选项算出来的,不是存的字段。
func (h *Handler) SybList(c *gin.Context) {
h.renderSybList(c, c.Query("order_no"), c.Query("page"), c.Query("msg"))
}
@@ -560,17 +559,6 @@ func appendSybFormState(params url.Values, c *gin.Context) {
}
}
// SybSelectSKU 保存采购员确认的蝦皮规格。Service 会再次验证 SKU 归属,
// 不能靠表单隐藏字段决定商品关系。
func (h *Handler) SybSelectSKU(c *gin.Context) {
err := service.AssignSybShopeeSKU(h.db, c.PostForm("syb_id"), c.PostForm("shopee_sku_id"))
if err != nil {
h.sybRedirect(c, "蝦皮规格未保存:"+err.Error())
return
}
h.sybRedirect(c, "蝦皮规格已确认,下一步按处理状态继续。")
}
// SybAssociatePdd 从货运单上下文关联 PDD 商品,实际业务规则与蝦皮详情共用。
func (h *Handler) SybAssociatePdd(c *gin.Context) {
goodsID, err := service.AssociateSybPdd(h.db, c.PostForm("syb_id"), c.PostForm("pdd_url"),
@@ -594,8 +582,8 @@ func (h *Handler) SybCollectPdd(c *gin.Context) {
// SybMatch 保存规格匹配结果。
//
// 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings),
// 不是这一张订单的临时数据。下次遇到同一个蝦皮 SKU 自动带出,
// 关键:保存的是**可复用的商品规格映射**(写 spec_mappings),
// 不是这一张订单的临时数据。下次遇到同一个商品和规格原文自动带出,
// 操作员只需确认。见 docs/admin/03-data-model.md §5。
func (h *Handler) SybMatch(c *gin.Context) {
actor := currentUser(c)
@@ -607,7 +595,7 @@ func (h *Handler) SybMatch(c *gin.Context) {
h.sybRedirect(c, "规格映射未保存:"+err.Error())
return
}
h.sybRedirect(c, "规格映射已保存;相同蝦皮规格和 PDD 商品会自动复用。")
h.sybRedirect(c, "规格映射已保存;相同蝦皮商品、顺运宝规格和 PDD 商品会自动复用。")
}
// SybCreateTask 由勾选的货运单创建采购任务。
@@ -615,7 +603,7 @@ func (h *Handler) SybMatch(c *gin.Context) {
// 六条校验缺一不可,见 docs/admin/01-requirements.md §5:
// 1. 已填 PDD 链接
// 2. 已采集成功(pdd_data 非空)
// 3. 已有 SKU 映射
// 3. 已有商品规格映射
// 4. 数量 > 0
// 5. **价格上限已填且 > 0**(Client 的价格保护,没有它会拒绝执行)
// 6. 已选择分配的客户端
-1
View File
@@ -76,7 +76,6 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
pages.GET("/syb/captcha", h.SybCaptcha) // 登录弹窗里的验证码图片
pages.POST("/syb/sync", h.SybSync)
pages.POST("/syb/login-and-sync", h.SybLoginAndSync)
pages.POST("/syb/select-sku", h.SybSelectSKU)
pages.POST("/syb/associate-pdd", h.SybAssociatePdd)
pages.POST("/syb/collect-pdd", h.SybCollectPdd)
pages.POST("/syb/match", h.SybMatch)
+25 -1
View File
@@ -58,7 +58,7 @@ func TestMainPagesReturnOK(t *testing.T) {
if err := service.SetupInitialAdmin(db, "admin", "test-password", "test-password", time.Now()); err != nil {
t.Fatalf("准备测试管理员失败: %v", err)
}
token, _, _, err := service.Login(db, "admin", "test-password", time.Now())
token, adminUser, _, err := service.Login(db, "admin", "test-password", time.Now())
if err != nil {
t.Fatalf("准备测试登录 Session 失败: %v", err)
}
@@ -84,6 +84,30 @@ func TestMainPagesReturnOK(t *testing.T) {
})
}
if err := service.CreatePurchaser(db, adminUser, "buyer", "buyer-password", "buyer-password", time.Now()); err != nil {
t.Fatalf("准备测试采购员失败: %v", err)
}
buyerToken, _, _, err := service.Login(db, "buyer", "buyer-password", time.Now())
if err != nil {
t.Fatalf("准备采购员 Session 失败: %v", err)
}
for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients"} {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("采购员 GET %s = %d,期望 200", path, response.Code)
}
}
usersRequest := httptest.NewRequest(http.MethodGet, "/users", nil)
usersRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
usersResponse := httptest.NewRecorder()
router.ServeHTTP(usersResponse, usersRequest)
if usersResponse.Code != http.StatusForbidden {
t.Fatalf("采购员 GET /users = %d,期望 403", usersResponse.Code)
}
sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil)
addAuth(sybRequest)
sybResponse := httptest.NewRecorder()
+16 -15
View File
@@ -168,6 +168,7 @@ type ShopeeProduct struct {
Title string
ShopeeStatus string
MainSKUCode string
Source string // report=蝦皮报表完整行;syb=顺运宝同步补建的商品骨架
PddGoodsURL string
PddGoodsID string // 指向 PddProduct.GoodsID,为空表示还没填链接
CreatedAt string
@@ -201,17 +202,16 @@ type ShopeeSKU struct {
// PriceTwdCent 是**台币分**,跟采购任务的人民币价格上限没有换算关系,
// 不要互相赋值,见 docs/admin/01-requirements.md §7。
//
// `[必须]` ShopeeSKUID 是蝦皮规格识别结果(人工确认或自动识别产生),
// 它不代表已经完成蝦皮 SKU 到 PDD SKU 的采购规格映射。
// 顺运宝同步**绝不能覆盖它**——顺运宝根本没有这个值,见工单 #46、
// docs/admin/08-顺运宝接口.md §6.2。
// ShopeeSKUID 是 #88 之前的蝦皮规格识别结果。数据库列因只追加迁移保留,
// 新同步和采购主链路均不再读写它。
type SybOrder struct {
SybID string
OrderNo string
Title string
ProductSpec string // 规格原文,顺运宝 productSpec,原样保留,对应 shopee_skus.spec_raw
SpecKey string // ProductSpec 的稳定身份键;空规格保持 SQL NULL,读出时为空字符串
ShopeeGoodsID string
ShopeeSKUID string // 蝦皮规格识别结果;顺运宝同步永远不覆盖这一列,见上面的注释
ShopeeSKUID string // 历史兼容字段;新主链路不使用
Quantity int
PriceTwdCent int64
ImageURL string
@@ -250,14 +250,14 @@ type SybSyncRun struct {
FinishedAt string
}
// SKUMapping 是「蝦皮的这个规格 = 拼多多的那个规格」。
// SpecMapping 是「这个蝦皮商品的顺运宝规格 = 拼多多的那个规格」。
//
// # 为什么主键要带上 PddGoodsID
//
// PDD 商品下架换代很频繁。假设蝦皮商品 X 原来对应 PDD 商品 A,
// 操作员匹配好了"黑色/M → 黑色/M码";后来 A 下架,换成了 B。
//
// 如果映射只按 ShopeeSKUID 存,那条旧映射还在,但它描述的是 **A 的规格**:
// 如果映射不带 PddGoodsID,那条旧映射还在,但它描述的是 **A 的规格**:
//
// - 运气好:B 没有"黑色/M码",建任务时找不到会报错,还算安全;
// - 运气坏:B 恰好也有"黑色/M码",但完全是另一件衣服
@@ -268,14 +268,15 @@ type SybSyncRun struct {
// 不需要在换商品时记得去删旧数据——靠查询条件天然隔离,忘不了。
//
// 附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。
type SKUMapping struct {
ShopeeSKUID string
PddGoodsID string // 这条映射属于哪个 PDD 商品
PddOptionKey string // 规范化后的组合键,见 service.OptionKey
PddOptions string // 原始 options 对象 JSON,显示用
GoodsID string // 蝦皮商品 ID,方便按商品批量查
MappedAt string
MappedBy string
type SpecMapping struct {
ShopeeGoodsID string
SpecKey string // 由 spec.SpecKey 生成,存和查必须使用同一实现
SpecRaw string // 保存当时的顺运宝原文,供人工核对
PddGoodsID string // 这条映射属于哪个 PDD 商品
PddOptionKey string // 规范化后的组合键,见 service.OptionKey
PddOptions string // 原始 options 对象 JSON,显示用
MappedAt string
MappedBy string
}
// ---------- 任务 ----------
+19 -19
View File
@@ -8,18 +8,18 @@ import (
"cmautobuy/admin/model"
)
// GetSKUMapping 读取某个蝦皮 SKU 在指定 PDD 商品下的映射。
func GetSKUMapping(q Execer, shopeeSKUID, pddGoodsID string) (*model.SKUMapping, error) {
var m model.SKUMapping
// GetSpecMapping 读取某个蝦皮商品的顺运宝规格在指定 PDD 商品下的映射。
func GetSpecMapping(q Execer, shopeeGoodsID, specKey, pddGoodsID string) (*model.SpecMapping, error) {
var m model.SpecMapping
var mappedBy sql.NullString
err := q.QueryRow(`
SELECT shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options,
goods_id, mapped_at, mapped_by
FROM sku_mappings
WHERE shopee_sku_id = ? AND pdd_goods_id = ?`,
shopeeSKUID, pddGoodsID).Scan(
&m.ShopeeSKUID, &m.PddGoodsID, &m.PddOptionKey, &m.PddOptions,
&m.GoodsID, &m.MappedAt, &mappedBy)
SELECT shopee_goods_id, spec_key, pdd_goods_id, pdd_option_key,
pdd_options, spec_raw, mapped_at, mapped_by
FROM spec_mappings
WHERE shopee_goods_id = ? AND spec_key = ? AND pdd_goods_id = ?`,
shopeeGoodsID, specKey, pddGoodsID).Scan(
&m.ShopeeGoodsID, &m.SpecKey, &m.PddGoodsID, &m.PddOptionKey,
&m.PddOptions, &m.SpecRaw, &m.MappedAt, &mappedBy)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -30,21 +30,21 @@ func GetSKUMapping(q Execer, shopeeSKUID, pddGoodsID string) (*model.SKUMapping,
return &m, nil
}
// UpsertSKUMapping 保存可复用规格映射,唯一键包含当前 PDD 商品。
func UpsertSKUMapping(q Execer, m model.SKUMapping) error {
// UpsertSpecMapping 保存可复用规格映射,唯一键包含当前 PDD 商品。
func UpsertSpecMapping(q Execer, m model.SpecMapping) error {
_, err := q.Exec(`
INSERT INTO sku_mappings
(shopee_sku_id, pdd_goods_id, pdd_option_key, pdd_options,
goods_id, mapped_at, mapped_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO spec_mappings
(shopee_goods_id, spec_key, pdd_goods_id, pdd_option_key,
pdd_options, spec_raw, mapped_at, mapped_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
pdd_option_key = VALUES(pdd_option_key),
pdd_options = VALUES(pdd_options),
goods_id = VALUES(goods_id),
spec_raw = VALUES(spec_raw),
mapped_at = VALUES(mapped_at),
mapped_by = VALUES(mapped_by)`,
m.ShopeeSKUID, m.PddGoodsID, m.PddOptionKey, m.PddOptions,
m.GoodsID, m.MappedAt, nullableText(m.MappedBy))
m.ShopeeGoodsID, m.SpecKey, m.PddGoodsID, m.PddOptionKey,
m.PddOptions, m.SpecRaw, m.MappedAt, nullableText(m.MappedBy))
if err != nil {
return fmt.Errorf("保存规格映射失败: %w", err)
}
+348 -5
View File
@@ -6,6 +6,7 @@ import (
"crypto/x509"
"database/sql"
"fmt"
"log"
"net"
"os"
"strings"
@@ -14,9 +15,11 @@ import (
"github.com/go-sql-driver/mysql"
"cmautobuy/admin/config"
"cmautobuy/admin/model"
"cmautobuy/admin/spec"
)
const mysqlSchemaVersion = 2
const mysqlSchemaVersion = 3
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -370,6 +373,20 @@ var mysqlSchemaV2 = []string{
ON DUPLICATE KEY UPDATE id = VALUES(id)`,
}
const mysqlSchemaV3SpecMappings = `CREATE TABLE IF NOT EXISTS spec_mappings (
shopee_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
pdd_goods_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
pdd_option_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL,
pdd_options LONGTEXT NOT NULL,
spec_raw TEXT NOT NULL,
mapped_at VARCHAR(35) NOT NULL,
mapped_by VARCHAR(191),
PRIMARY KEY (shopee_goods_id, spec_key, pdd_goods_id),
KEY idx_spec_mappings_goods (shopee_goods_id),
KEY idx_spec_mappings_pdd (pdd_goods_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`
// MigrateMySQL 建立或升级 MySQL schema。生产迁移只能在这里追加新版本。
func MigrateMySQL(db *sql.DB) error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
@@ -406,21 +423,274 @@ func MigrateMySQL(db *sql.DB) error {
return fmt.Errorf("执行 MySQL schema v2 第 %d 条失败: %w", i+1, err)
}
}
if err := CheckMySQLSchema(db); err != nil {
mysqlRequiredTablesV2 := append(append([]string{}, requiredTables...), "admin_initialization_lock")
if err := checkMySQLSchema(db, mysqlRequiredTablesV2); err != nil {
return fmt.Errorf("MySQL schema v2 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
2, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v2 失败: %w", err)
}
current = 2
}
if current < 3 {
if err := migrateMySQLV3(db); err != nil {
return fmt.Errorf("执行 MySQL schema v3 失败: %w", err)
}
if err := CheckMySQLSchema(db); err != nil {
return fmt.Errorf("MySQL schema v3 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
3, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v3 失败: %w", err)
}
}
return CheckMySQLSchema(db)
}
// CheckMySQLSchema 确认所有业务表和关键追加列存在。
// migrateMySQLV3 把采购规格身份从蝦皮 SKU 改为顺运宝商品规格原文。
// 每一步都可重放:DDL 已提交但版本尚未记录时,再启动仍会收敛。
func migrateMySQLV3(db *sql.DB) error {
if err := ensureMySQLV3Columns(db); err != nil {
return err
}
if _, err := db.Exec(mysqlSchemaV3SpecMappings); err != nil {
return fmt.Errorf("建立 spec_mappings 失败: %w", err)
}
if err := backfillSybSpecKeys(db); err != nil {
return err
}
if err := ensureSybShopeeSkeletons(db); err != nil {
return err
}
return convertLegacySKUMappings(db)
}
func ensureMySQLV3Columns(db *sql.DB) error {
exists, err := mysqlColumnExists(db, "syb_orders", "spec_key")
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(`ALTER TABLE syb_orders ADD COLUMN spec_key VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER product_spec`); err != nil {
return fmt.Errorf("增加 syb_orders.spec_key 失败: %w", err)
}
} else if _, err := db.Exec(`ALTER TABLE syb_orders MODIFY COLUMN spec_key VARCHAR(191) COLLATE utf8mb4_bin NULL`); err != nil {
return fmt.Errorf("校正 syb_orders.spec_key 结构失败: %w", err)
}
exists, err = mysqlColumnExists(db, "shopee_products", "source")
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(`ALTER TABLE shopee_products ADD COLUMN source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report' AFTER main_sku_code`); err != nil {
return fmt.Errorf("增加 shopee_products.source 失败: %w", err)
}
} else {
// 兼容“列已加但还是可空”的 DDL 中断点。先回填旧行,
// 再收紧 NOT NULL,否则带 NULL 的存量数据会让 ALTER 失败。
if _, err := db.Exec(`UPDATE shopee_products SET source='report' WHERE source IS NULL OR source=''`); err != nil {
return fmt.Errorf("回填 shopee_products.source 失败: %w", err)
}
if _, err := db.Exec(`ALTER TABLE shopee_products MODIFY COLUMN source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report'`); err != nil {
return fmt.Errorf("校正 shopee_products.source 结构失败: %w", err)
}
}
constraintExists, err := mysqlConstraintExists(db, "shopee_products", "chk_shopee_products_source")
if err != nil {
return err
}
if !constraintExists {
if _, err := db.Exec(`ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_source CHECK (source IN ('report','syb'))`); err != nil {
return fmt.Errorf("增加 shopee_products.source CHECK 失败: %w", err)
}
}
return nil
}
func mysqlColumnExists(db *sql.DB, table, column string) (bool, error) {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name=? AND column_name=?`, table, column).Scan(&count); err != nil {
return false, fmt.Errorf("检查 MySQL 数据表 %s.%s 失败: %w", table, column, err)
}
return count == 1, nil
}
func mysqlConstraintExists(db *sql.DB, table, constraint string) (bool, error) {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name=? AND constraint_name=?`, table, constraint).Scan(&count); err != nil {
return false, fmt.Errorf("检查 MySQL 约束 %s.%s 失败: %w", table, constraint, err)
}
return count == 1, nil
}
func backfillSybSpecKeys(db *sql.DB) error {
const batchSize = 500
cursor := ""
for {
rows, err := db.Query(`SELECT syb_id, product_spec FROM syb_orders
WHERE syb_id > ? AND spec_key IS NULL AND product_spec IS NOT NULL
AND TRIM(product_spec) <> '' ORDER BY syb_id LIMIT ?`, cursor, batchSize)
if err != nil {
return fmt.Errorf("读取待回填顺运宝规格失败: %w", err)
}
type item struct{ id, raw, key string }
items := make([]item, 0, batchSize)
for rows.Next() {
var value item
if err := rows.Scan(&value.id, &value.raw); err != nil {
rows.Close()
return fmt.Errorf("读取待回填顺运宝规格失败: %w", err)
}
value.key, err = spec.SpecKey(value.raw)
if err != nil {
rows.Close()
return fmt.Errorf("顺运宝明细 %s 的规格不能生成身份键: %w", value.id, err)
}
items = append(items, value)
}
if err := rows.Close(); err != nil {
return fmt.Errorf("关闭顺运宝规格回填结果失败: %w", err)
}
if len(items) == 0 {
return nil
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开始顺运宝规格回填事务失败: %w", err)
}
for _, value := range items {
if _, err := tx.Exec(`UPDATE syb_orders SET spec_key=? WHERE syb_id=? AND spec_key IS NULL`, value.key, value.id); err != nil {
tx.Rollback()
return fmt.Errorf("回填顺运宝明细 %s 的规格键失败: %w", value.id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交顺运宝规格回填失败: %w", err)
}
cursor = items[len(items)-1].id
}
}
func ensureSybShopeeSkeletons(db *sql.DB) error {
now := model.NowISO()
_, err := db.Exec(`INSERT INTO shopee_products
(goods_id, title, source, created_at, updated_at)
SELECT shopee_goods_id, MAX(COALESCE(title,'')), 'syb', ?, ?
FROM syb_orders
WHERE shopee_goods_id IS NOT NULL AND shopee_goods_id <> ''
GROUP BY shopee_goods_id
ON DUPLICATE KEY UPDATE goods_id=VALUES(goods_id)`, now, now)
if err != nil {
return fmt.Errorf("按顺运宝数据补建蝦皮商品骨架失败: %w", err)
}
return nil
}
func convertLegacySKUMappings(db *sql.DB) error {
exists, err := mysqlTableExists(db, "sku_mappings")
if err != nil || !exists {
return err
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS sku_mappings_v3_backup LIKE sku_mappings`); err != nil {
return fmt.Errorf("建立旧规格映射备份表失败: %w", err)
}
if _, err := db.Exec(`INSERT IGNORE INTO sku_mappings_v3_backup SELECT * FROM sku_mappings`); err != nil {
return fmt.Errorf("备份旧规格映射失败: %w", err)
}
rows, err := db.Query(`SELECT m.shopee_sku_id, m.pdd_goods_id, m.pdd_option_key,
m.pdd_options, m.mapped_at, m.mapped_by, sk.goods_id, sk.spec_raw
FROM sku_mappings m LEFT JOIN shopee_skus sk ON sk.sku_id=m.shopee_sku_id`)
if err != nil {
return fmt.Errorf("读取旧规格映射失败: %w", err)
}
type legacy struct {
skuID, pddID, optionKey, options, mappedAt string
mappedBy, goodsID, raw sql.NullString
}
var records []legacy
for rows.Next() {
var value legacy
if err := rows.Scan(&value.skuID, &value.pddID, &value.optionKey, &value.options,
&value.mappedAt, &value.mappedBy, &value.goodsID, &value.raw); err != nil {
rows.Close()
return fmt.Errorf("读取旧规格映射失败: %w", err)
}
records = append(records, value)
}
if err := rows.Close(); err != nil {
return fmt.Errorf("关闭旧规格映射结果失败: %w", err)
}
matched, noCurrentMatch, unable := 0, 0, 0
for _, value := range records {
if !value.goodsID.Valid || !value.raw.Valid {
unable++
log.Printf("mysql_v3_mapping_unconvertible shopee_sku_id=%s pdd_goods_id=%s", value.skuID, value.pddID)
continue
}
key, err := spec.SpecKey(value.raw.String)
if err != nil {
unable++
log.Printf("mysql_v3_mapping_unconvertible shopee_sku_id=%s pdd_goods_id=%s reason=invalid_spec_key", value.skuID, value.pddID)
continue
}
if err := UpsertSpecMapping(db, model.SpecMapping{
ShopeeGoodsID: value.goodsID.String, SpecKey: key, SpecRaw: value.raw.String,
PddGoodsID: value.pddID, PddOptionKey: value.optionKey, PddOptions: value.options,
MappedAt: value.mappedAt, MappedBy: value.mappedBy.String,
}); err != nil {
return fmt.Errorf("转换旧规格映射 %s 失败: %w", value.skuID, err)
}
var hit int
if err := db.QueryRow(`SELECT EXISTS(SELECT 1 FROM syb_orders WHERE shopee_goods_id=? AND spec_key=?)`,
value.goodsID.String, key).Scan(&hit); err != nil {
return fmt.Errorf("验证旧规格映射 %s 命中失败: %w", value.skuID, err)
}
if hit == 1 {
matched++
} else {
noCurrentMatch++
log.Printf("mysql_v3_mapping_no_current_hit shopee_sku_id=%s pdd_goods_id=%s", value.skuID, value.pddID)
}
}
if matched+noCurrentMatch+unable != len(records) {
return fmt.Errorf("旧规格映射迁移计数不守恒: 总数=%d 命中=%d 无命中=%d 无法转换=%d",
len(records), matched, noCurrentMatch, unable)
}
log.Printf("mysql_v3_mapping_summary total=%d matched=%d no_current_hit=%d unconvertible=%d backup_table=sku_mappings_v3_backup",
len(records), matched, noCurrentMatch, unable)
if _, err := db.Exec(`DROP TABLE sku_mappings`); err != nil {
return fmt.Errorf("删除已备份的旧规格映射表失败: %w", err)
}
return nil
}
func mysqlTableExists(db *sql.DB, table string) (bool, error) {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name=? AND table_type='BASE TABLE'`, table).Scan(&count); err != nil {
return false, fmt.Errorf("检查 MySQL 数据表 %s 失败: %w", table, err)
}
return count == 1, nil
}
// CheckMySQLSchema 确认所有业务表、关键追加列和采购身份结构完整。
func CheckMySQLSchema(db *sql.DB) error {
mysqlRequiredTables := append(append([]string{}, requiredTables...), "admin_initialization_lock")
return checkMySQLSchema(db, mysqlRequiredTables)
mysqlRequiredTables := []string{
"shopee_products", "shopee_skus", "pdd_products", "syb_orders", "spec_mappings",
"tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state",
"users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock",
}
if err := checkMySQLSchema(db, mysqlRequiredTables); err != nil {
return err
}
return checkMySQLV3Shape(db)
}
func checkMySQLSchema(db *sql.DB, tables []string) error {
@@ -465,3 +735,76 @@ func checkMySQLSchema(db *sql.DB, tables []string) error {
}
return nil
}
func checkMySQLV3Shape(db *sql.DB) error {
rows, err := db.Query(`SELECT column_name FROM information_schema.key_column_usage
WHERE table_schema=DATABASE() AND table_name='spec_mappings'
AND constraint_name='PRIMARY' ORDER BY ordinal_position`)
if err != nil {
return fmt.Errorf("检查 spec_mappings 主键失败: %w", err)
}
var primary []string
for rows.Next() {
var column string
if err := rows.Scan(&column); err != nil {
rows.Close()
return fmt.Errorf("检查 spec_mappings 主键失败: %w", err)
}
primary = append(primary, column)
}
rows.Close()
if strings.Join(primary, ",") != "shopee_goods_id,spec_key,pdd_goods_id" {
return fmt.Errorf("spec_mappings 主键不正确,实际为 (%s)", strings.Join(primary, ","))
}
for _, column := range []string{"shopee_goods_id", "spec_key", "pdd_goods_id"} {
if err := checkMySQLVarcharColumn(db, "spec_mappings", column, 191, false, "utf8mb4_bin", ""); err != nil {
return err
}
}
if err := checkMySQLVarcharColumn(db, "syb_orders", "spec_key", 191, true, "utf8mb4_bin", ""); err != nil {
return err
}
if err := checkMySQLVarcharColumn(db, "shopee_products", "source", 16, false, "utf8mb4_bin", "report"); err != nil {
return err
}
var enforced, checkClause string
if err := db.QueryRow(`SELECT tc.enforced, cc.check_clause
FROM information_schema.table_constraints tc
JOIN information_schema.check_constraints cc
ON cc.constraint_schema=tc.constraint_schema AND cc.constraint_name=tc.constraint_name
WHERE tc.constraint_schema=DATABASE() AND tc.table_name='shopee_products'
AND tc.constraint_name='chk_shopee_products_source' AND tc.constraint_type='CHECK'`).Scan(&enforced, &checkClause); err != nil {
return fmt.Errorf("shopee_products.source CHECK 缺失或不可读: %w", err)
}
if enforced != "YES" {
return fmt.Errorf("shopee_products.source CHECK 未启用")
}
normalizedClause := strings.NewReplacer("`", "", " ", "", "(", "", ")", "", "_utf8mb4", "", `\`, "").Replace(strings.ToLower(checkClause))
if !strings.Contains(normalizedClause, "sourcein'report','syb'") {
return fmt.Errorf("shopee_products.source CHECK 表达式不正确")
}
return nil
}
func checkMySQLVarcharColumn(db *sql.DB, table, column string, length int64, nullable bool, collation, defaultValue string) error {
var dataType, isNullable string
var actualLength sql.NullInt64
var actualCollation, actualDefault sql.NullString
if err := db.QueryRow(`SELECT data_type, character_maximum_length, is_nullable, collation_name, column_default
FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=?`,
table, column).Scan(&dataType, &actualLength, &isNullable, &actualCollation, &actualDefault); err != nil {
return fmt.Errorf("检查 MySQL 列 %s.%s 结构失败: %w", table, column, err)
}
wantNullable := "NO"
if nullable {
wantNullable = "YES"
}
if dataType != "varchar" || !actualLength.Valid || actualLength.Int64 != length ||
isNullable != wantNullable || actualCollation.String != collation {
return fmt.Errorf("MySQL 列 %s.%s 结构不正确", table, column)
}
if defaultValue != "" && (!actualDefault.Valid || actualDefault.String != defaultValue) {
return fmt.Errorf("MySQL 列 %s.%s 默认值不正确", table, column)
}
return nil
}
@@ -8,6 +8,7 @@ import (
"testing"
"cmautobuy/admin/config"
"cmautobuy/admin/spec"
)
// TestMySQLMigrate_真实MySQL8 只在显式提供隔离测试库时运行。
@@ -46,6 +47,180 @@ func TestMySQLMigrate_真实MySQL8(t *testing.T) {
}
}
func TestMySQLMigrate_V2升级V3并转换真实主链路(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
prepareMySQLV2(t, db)
now := "2026-08-10T03:00:00Z"
mustExec(t, db, `INSERT INTO shopee_products (goods_id,title,created_at,updated_at) VALUES ('REPORT-1','报表商品',?,?)`, now, now)
mustExec(t, db, `INSERT INTO shopee_skus (sku_id,goods_id,spec_raw,created_at,updated_at) VALUES ('SKU-1','REPORT-1','黑色, M',?,?)`, now, now)
mustExec(t, db, `INSERT INTO sku_mappings (shopee_sku_id,pdd_goods_id,pdd_option_key,pdd_options,goods_id,mapped_at) VALUES ('SKU-1','PDD-1','color=黑色&size=M','color=黑色&size=M','REPORT-1',?)`, now)
mustExec(t, db, `INSERT INTO syb_orders (syb_id,order_no,title,shopee_goods_id,product_spec,quantity,syb_data,created_at,updated_at) VALUES
('SYB-1','ORDER-1','报表商品','REPORT-1',' 黑色, M ',1,'{}',?,?),
('SYB-2','ORDER-2','骨架商品','SKELETON-1','白色,L',1,'{}',?,?),
('SYB-3','ORDER-3','空规格','EMPTY-1',NULL,1,'{}',?,?)`, now, now, now, now, now, now)
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v2→v3 失败: %v", err)
}
var key string
if err := db.QueryRow(`SELECT spec_key FROM syb_orders WHERE syb_id='SYB-1'`).Scan(&key); err != nil || key != "黑色, M" {
t.Fatalf("规格回填=%q err=%v", key, err)
}
var empty sql.NullString
if err := db.QueryRow(`SELECT spec_key FROM syb_orders WHERE syb_id='SYB-3'`).Scan(&empty); err != nil || empty.Valid {
t.Fatalf("空规格必须保持 NULL: %+v err=%v", empty, err)
}
var source string
if err := db.QueryRow(`SELECT source FROM shopee_products WHERE goods_id='SKELETON-1'`).Scan(&source); err != nil || source != "syb" {
t.Fatalf("骨架来源=%q err=%v", source, err)
}
var mappingCount int
if err := db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='REPORT-1' AND spec_key='黑色, M' AND pdd_goods_id='PDD-1'`).Scan(&mappingCount); err != nil || mappingCount != 1 {
t.Fatalf("旧映射转换数量=%d err=%v", mappingCount, err)
}
if exists, _ := mysqlTableExists(db, "sku_mappings"); exists {
t.Fatal("旧 sku_mappings 应已删除")
}
if exists, _ := mysqlTableExists(db, "sku_mappings_v3_backup"); !exists {
t.Fatal("删除旧表前必须保留受限备份表")
}
}
func TestMySQLMigrate_V3三个中断点重跑收敛(t *testing.T) {
for _, tc := range []struct {
name string
prepare func(*testing.T, *sql.DB)
}{
{"DDL完成回填未开始", func(t *testing.T, db *sql.DB) {
if err := ensureMySQLV3Columns(db); err != nil {
t.Fatal(err)
}
mustExec(t, db, mysqlSchemaV3SpecMappings)
}},
{"回填到一半", func(t *testing.T, db *sql.DB) {
if err := ensureMySQLV3Columns(db); err != nil {
t.Fatal(err)
}
mustExec(t, db, mysqlSchemaV3SpecMappings)
key, _ := spec.SpecKey("黑色,M")
mustExec(t, db, `UPDATE syb_orders SET spec_key=? WHERE syb_id='SYB-A'`, key)
}},
{"全部完成版本未记录", func(t *testing.T, db *sql.DB) {
if err := migrateMySQLV3(db); err != nil {
t.Fatal(err)
}
}},
} {
t.Run(tc.name, func(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
prepareMySQLV2(t, db)
now := "2026-08-10T03:00:00Z"
mustExec(t, db, `INSERT INTO syb_orders (syb_id,order_no,title,shopee_goods_id,product_spec,quantity,syb_data,created_at,updated_at) VALUES ('SYB-A','A','A','G-A','黑色,M',1,'{}',?,?),('SYB-B','B','B','G-B','白色,L',1,'{}',?,?)`, now, now, now, now)
tc.prepare(t, db)
if err := MigrateMySQL(db); err != nil {
t.Fatalf("重跑失败: %v", err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatalf("再次重跑失败: %v", err)
}
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM syb_orders WHERE spec_key IS NOT NULL`).Scan(&count); err != nil || count != 2 {
t.Fatalf("回填未收敛 count=%d err=%v", count, err)
}
})
}
}
func TestMySQLMigrate_V3字段存在但约束缺失可收敛(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
prepareMySQLV2(t, db)
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,created_at,updated_at) VALUES ('EXISTING-1','存量商品','2026-08-10T00:00:00Z','2026-08-10T00:00:00Z')`)
mustExec(t, db, `ALTER TABLE shopee_products ADD COLUMN source VARCHAR(16) NULL`)
if err := MigrateMySQL(db); err != nil {
t.Fatal(err)
}
var existingSource string
if err := db.QueryRow(`SELECT source FROM shopee_products WHERE goods_id='EXISTING-1'`).Scan(&existingSource); err != nil || existingSource != "report" {
t.Fatalf("可空 source 中断点的存量行未收敛: source=%q err=%v", existingSource, err)
}
mustExec(t, db, `INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES ('CHECK-1','检查','report','2026-08-10T00:00:00Z','2026-08-10T00:00:00Z')`)
if _, err := db.Exec(`UPDATE shopee_products SET source='typo'`); err == nil {
t.Fatal("source CHECK 必须拒绝非法值")
}
}
func TestMySQLMigrate_V3形状自检失败不记版本(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
prepareMySQLV2(t, db)
mustExec(t, db, `CREATE TABLE spec_mappings (
shopee_goods_id VARCHAR(191) NOT NULL,
spec_key VARCHAR(191) NOT NULL,
pdd_goods_id VARCHAR(191) NOT NULL,
pdd_option_key VARCHAR(191) NOT NULL,
pdd_options LONGTEXT NOT NULL,
spec_raw TEXT NOT NULL,
mapped_at VARCHAR(35) NOT NULL,
PRIMARY KEY (shopee_goods_id, pdd_goods_id, spec_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`)
if err := MigrateMySQL(db); err == nil {
t.Fatal("错误主键顺序必须让 v3 形状自检失败")
}
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=3`).Scan(&count); err != nil || count != 0 {
t.Fatalf("自检失败时不得记录 v3: count=%d err=%v", count, err)
}
}
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
t.Helper()
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
t.Skip("未启用真实 MySQL 8 集成测试")
}
cfg, err := config.LoadDatabaseFromEnv()
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(cfg.Name, "_test") {
t.Fatalf("拒绝使用非 _test 数据库 %q", cfg.Name)
}
db, err := OpenMySQL(cfg)
if err != nil {
t.Fatal(err)
}
return db
}
func prepareMySQLV2(t *testing.T, db *sql.DB) {
t.Helper()
mustExec(t, db, `CREATE TABLE schema_migrations (version INT PRIMARY KEY, applied_at VARCHAR(35) NOT NULL) ENGINE=InnoDB`)
for _, statement := range mysqlSchemaV1 {
mustExec(t, db, statement)
}
for _, statement := range mysqlSchemaV2 {
mustExec(t, db, statement)
}
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (1,'2026-08-10T00:00:00Z'),(2,'2026-08-10T00:00:00Z')`)
}
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatalf("执行 SQL 失败: %v", err)
}
}
func cleanMySQLTestSchema(t *testing.T, db *sql.DB) {
t.Helper()
rows, err := db.Query(`SELECT table_name FROM information_schema.tables
+2 -2
View File
@@ -194,7 +194,7 @@ func pddListFilter(keyword string, status model.CollectStatus) (string, []any) {
// ListPddProducts 查一页列表和当前筛选下的总数。
// keyword 匹配 goods_id 或链接,status 为空表示不筛选。
//
// 只返回未删除的。软删除的记录还在库里(sku_mappings 指着它),
// 只返回未删除的。软删除的记录还在库里(spec_mappings 仍会引用它),
// 但界面上不该看见。
func ListPddProducts(q Execer, keyword string, status model.CollectStatus, limit, offset int) ([]PddProductRow, int, error) {
where, args := pddListFilter(keyword, status)
@@ -414,7 +414,7 @@ func MarkCollecting(q Execer, pddGoodsID string) (bool, error) {
// SoftDeletePddProduct 软删除。
//
// 不硬删是因为 sku_mappings 指向它,硬删会把人工攒了很久的匹配成果一起带走。
// 不硬删是因为 spec_mappings 指向它,硬删会把人工攒了很久的匹配成果一起带走。
// 删除后界面上不再显示,但记录和映射都还在;
// 操作员重新填同一个链接时会被 EnsurePddProduct 复活。
func SoftDeletePddProduct(q Execer, pddGoodsID string) error {
+11 -8
View File
@@ -22,12 +22,13 @@ func UpsertShopeeProduct(q Execer, goodsID, title, shopeeStatus, mainSKUCode str
}
now := model.NowISO()
_, err := q.Exec(`
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, source, created_at, updated_at)
VALUES (?, ?, ?, ?, 'report', ?, ?)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
shopee_status = VALUES(shopee_status),
main_sku_code = VALUES(main_sku_code),
source = 'report',
updated_at = VALUES(updated_at)`,
// 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里,
// 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接),
@@ -162,7 +163,7 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) {
where, args := shopeeFilterClause(filter)
sqlText := `
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code,
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code, sp.source,
sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at,
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.color END) AS color_count,
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.size END) AS size_count,
@@ -185,9 +186,9 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
var list []ShopeeProductRow
for rows.Next() {
var r ShopeeProductRow
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
var title, shopeeStatus, mainSKUCode, source, pddGoodsURL, pddGoodsID sql.NullString
if err := rows.Scan(
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode,
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode, &source,
&pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt,
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
&r.CollectStatus, &r.CollectMsg,
@@ -197,6 +198,7 @@ func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]Sho
r.Title = title.String
r.ShopeeStatus = shopeeStatus.String
r.MainSKUCode = mainSKUCode.String
r.Source = source.String
r.PddGoodsURL = pddGoodsURL.String
r.PddGoodsID = pddGoodsID.String
list = append(list, r)
@@ -223,12 +225,12 @@ func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) {
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
var p model.ShopeeProduct
var title, shopeeStatus, mainSKUCode, pddGoodsURL, pddGoodsID sql.NullString
var title, shopeeStatus, mainSKUCode, source, pddGoodsURL, pddGoodsID sql.NullString
err := q.QueryRow(`
SELECT goods_id, title, shopee_status, main_sku_code,
SELECT goods_id, title, shopee_status, main_sku_code, source,
pdd_goods_url, pdd_goods_id, created_at, updated_at
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode,
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &source,
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -239,6 +241,7 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct,
p.Title = title.String
p.ShopeeStatus = shopeeStatus.String
p.MainSKUCode = mainSKUCode.String
p.Source = source.String
p.PddGoodsURL = pddGoodsURL.String
p.PddGoodsID = pddGoodsID.String
return &p, nil
+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)
+19
View File
@@ -167,6 +167,25 @@ func TestUpsertSybOrder_重复同步同一订单行数不翻倍(t *testing.T) {
}
}
func TestUpsertSybOrder_骨架失败时同事务不写订单(t *testing.T) {
db := newSybTestDB(t)
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if _, err := UpsertSybOrder(tx, model.SybOrder{
SybID: "SYB-REJECT", OrderNo: "ORDER-REJECT", Title: "测试",
ShopeeGoodsID: strings.Repeat("G", 192), ProductSpec: "黑色,M", Quantity: 1, SybData: "{}",
}); err == nil {
t.Fatal("骨架失败必须让订单写入失败")
}
tx.Rollback()
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM syb_orders WHERE syb_id='SYB-REJECT'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("失败后不应留下订单 count=%d err=%v", count, err)
}
}
func TestSybSession_保存读取和清除(t *testing.T) {
db := newSybTestDB(t)
+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)
}
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package spec 提供顺运宝规格身份键的唯一规范化实现。
package spec
import (
"fmt"
"strings"
"unicode/utf8"
)
// MaxKeyRunes 是数据库 VARCHAR(191) 身份列允许的最大字符数。
const MaxKeyRunes = 191
// SpecKey 把顺运宝规格原文规范化成稳定身份键。
//
// 它只去掉首尾空白并把连续空白折叠为一个空格,不做颜色、尺码或繁简
// 推断。空规格和超过数据库列宽的规格返回错误,调用方不得截断。
func SpecKey(raw string) (string, error) {
key := strings.Join(strings.Fields(raw), " ")
if key == "" {
return "", fmt.Errorf("规格原文不能为空")
}
if utf8.RuneCountInString(key) > MaxKeyRunes {
return "", fmt.Errorf("规格身份键超过 %d 个字符", MaxKeyRunes)
}
return key, nil
}
+35
View File
@@ -0,0 +1,35 @@
package spec
import (
"strings"
"testing"
)
func TestSpecKey_只折叠空白(t *testing.T) {
got, err := SpecKey(" 黑色,\tM【建議40-50公斤】 \n")
if err != nil {
t.Fatal(err)
}
if got != "黑色, M【建議40-50公斤】" {
t.Fatalf("SpecKey=%q", got)
}
}
func TestSpecKey_不依赖规格解析(t *testing.T) {
got, err := SpecKey("紅色,3XL建議80-90公斤】")
if err != nil {
t.Fatal(err)
}
if got != "紅色,3XL建議80-90公斤】" {
t.Fatalf("SpecKey=%q", got)
}
}
func TestSpecKey_拒绝空值和超长值(t *testing.T) {
if _, err := SpecKey(" \t\n "); err == nil {
t.Fatal("空规格必须报错")
}
if _, err := SpecKey(strings.Repeat("规", MaxKeyRunes+1)); err == nil {
t.Fatal("超长规格必须报错")
}
}
+3 -1
View File
@@ -51,6 +51,7 @@
聚合成商品级之后,"部分失败"的行光看数字是正常的,
不加这一列坏数据就会被悄悄吃掉,见 #41。 */}}
<th>待补</th>
<th>来源</th>
<th>PDD 链接</th>
<th>采集状态</th>
<th>更新时间</th>
@@ -69,13 +70,14 @@
<td>{{.SizeCount}}</td>
<td>{{.SKUCount}}</td>
<td>{{.PendingCount}}</td>
<td>{{.SourceText}}</td>
<td{{if .PddMissing}} class="missing"{{end}}>{{.PddURL}}</td>
<td>{{.StatusText}}</td>
<td>{{.UpdatedAt}}</td>
</tr>
{{else}}
<tr class="empty">
<td colspan="10">
<td colspan="11">
{{if .IsFiltered}}
没有匹配的数据,换个商品 ID 或商品名称试试。
{{else}}
+5 -31
View File
@@ -11,39 +11,13 @@
<dl class="detail">
<dt>明细 ID</dt><dd>{{.SybID}}</dd>
<dt>商品</dt><dd>{{.Title}}</dd>
<dt>顺运宝规格</dt><dd>{{.ProductSpec}}</dd>
<dt>顺运宝规格</dt><dd>{{if .ProductSpec}}{{.ProductSpec}}{{else}}—{{end}}</dd>
<dt>数量</dt><dd>{{.Quantity}}</dd>
<dt>蝦皮商品 ID</dt><dd>{{.ShopeeGoodsID}}</dd>
<dt>蝦皮规格 ID</dt><dd>{{if .ShopeeSKUID}}{{.ShopeeSKUID}}{{else}}—{{end}}</dd>
</dl>
{{if not .ShopeeExists}}
<p class="missing">蝦皮数据中找不到商品 {{.ShopeeGoodsID}}。请先导入该商品,或核对顺运宝商品链接提取出的 ID。</p>
<p><a class="button-link" href="/shopee?goods_id={{.ShopeeGoodsID}}">到蝦皮数据查看</a></p>
{{else if and (not .ShopeeSKUID) (not .Candidates)}}
<p class="missing">这个蝦皮商品没有可选 SKU。请先在蝦皮数据中补充规格。</p>
<p><a class="button-link" href="/shopee?goods_id={{.ShopeeGoodsID}}">到蝦皮数据补充</a></p>
{{else if not .ShopeeSKUID}}
<form method="post" action="/syb/select-sku" class="form-stack">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="syb_id" value="{{.SybID}}">
<input type="hidden" name="order_no" value="{{$.Keyword}}">
<input type="hidden" name="stage" value="{{$.StageFilter}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}">
<div class="field">
<label for="syb-shopee-sku">蝦皮规格</label>
<select id="syb-shopee-sku" name="shopee_sku_id" required autofocus>
<option value="">请选择</option>
{{range .Candidates}}
<option value="{{.SKUID}}" {{if .Selected}}selected{{end}}>{{.SpecRaw}}({{.Color}} / {{.Size}},ID {{.SKUID}})</option>
{{end}}
</select>
<small>系统只会自动采用唯一精确候选;这里的选择仍会由服务端校验是否属于当前商品。</small>
</div>
<div>
<button type="submit" class="primary">确认蝦皮规格</button>
</div>
</form>
{{if eq .Stage "spec_missing"}}
<p class="missing" role="alert">顺运宝没有提供规格原文,系统无法安全判断要采购的颜色和尺码。请先核对上游货运单数据。</p>
{{else}}
<h3>关联采购商品</h3>
<form method="post" action="/syb/associate-pdd" class="form-stack">
@@ -87,14 +61,14 @@
<input type="hidden" name="stage" value="{{$.StageFilter}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}">
<div class="field">
<label for="syb-pdd-option">蝦皮 {{.ProductSpec}} 对应的 PDD 规格</label>
<label for="syb-pdd-option">顺运宝 {{.ProductSpec}} 对应的 PDD 规格</label>
<select id="syb-pdd-option" name="pdd_option_key" required>
<option value="">请选择,不做相似规格猜测</option>
{{range .PddChoices}}
<option value="{{.Key}}" {{if .Selected}}selected{{end}}>{{.Label}} · {{.PriceText}}</option>
{{end}}
</select>
<small>PDD 维度来自采集结果:{{range $i, $name := .PddDimensionNames}}{{if $i}} / {{end}}{{$name}}{{end}}。保存后相同蝦皮 SKU + 当前 PDD 商品会复用。</small>
<small>PDD 维度来自采集结果:{{range $i, $name := .PddDimensionNames}}{{if $i}} / {{end}}{{$name}}{{end}}。保存后相同蝦皮商品 + 顺运宝规格 + 当前 PDD 商品会复用。</small>
</div>
{{if and (not .MappingValid) .PddChoices}}<p class="missing">尚无有效映射,或原映射已不在最新采集结果中,请重新选择。</p>{{end}}
<div><button type="submit" class="primary">保存规格映射</button></div>
+1 -3
View File
@@ -91,7 +91,6 @@
<th>商品标题</th>
<th>规格</th>
<th>蝦皮商品 ID</th>
<th>规格 SKU</th>
<th>数量</th>
<th>价格</th>
<th>图片</th>
@@ -112,7 +111,6 @@
<td class="truncate" title="{{.Title}}">{{.Title}}</td>
<td class="truncate" title="{{.ProductSpec}}">{{.ProductSpec}}</td>
<td>{{.ShopeeGoodsID}}</td>
<td>{{if .ShopeeSKUID}}{{.ShopeeSKUID}}{{else}}—{{end}}</td>
<td>{{.Quantity}}</td>
<td>{{.PriceText}}</td>
<td>
@@ -124,7 +122,7 @@
</tr>
{{else}}
<tr class="empty">
<td colspan="13">
<td colspan="12">
{{if .IsFiltered}}
当前筛选条件下没有货运单明细。<br>
<small>换个订单号或清空搜索词再试。<a href="/syb">查看全部</a></small>