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()
+11 -10
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,12 +268,13 @@ type SybSyncRun struct {
// 不需要在换商品时记得去删旧数据——靠查询条件天然隔离,忘不了。
//
// 附带好处:A 的映射还留着。A 补货换回去时,之前的匹配成果直接复用。
type SKUMapping struct {
ShopeeSKUID string
type SpecMapping struct {
ShopeeGoodsID string
SpecKey string // 由 spec.SpecKey 生成,存和查必须使用同一实现
SpecRaw string // 保存当时的顺运宝原文,供人工核对
PddGoodsID string // 这条映射属于哪个 PDD 商品
PddOptionKey string // 规范化后的组合键,见 service.OptionKey
PddOptions string // 原始 options 对象 JSON,显示用
GoodsID string // 蝦皮商品 ID,方便按商品批量查
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)
}
+11 -128
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,8 +770,6 @@ 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),
@@ -784,9 +780,6 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
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,7 +1167,7 @@ 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,
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
}
+69 -94
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) {
func TestUpsertSybOrder_补建骨架并由报表提升来源(t *testing.T) {
db := newSyncTestDB(t)
if err := repository.UpsertShopeeProduct(db, "SP-1", "商品", "", ""); err != nil {
seedWorkflowOrder(t, db, "SYB-1", "SP-1", "黑色, M")
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)
}
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 := repository.UpsertShopeeProduct(db, "SP-1", "报表标题", "正常", "MAIN-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)
product, err = repository.GetShopeeProductByGoodsID(db, "SP-1")
if err != nil || product.Source != "report" || product.Title != "报表标题" {
t.Fatalf("报表导入应提升骨架来源: product=%+v err=%v", product, err)
}
})
t.Run("解析后颜色尺码唯一一致", func(t *testing.T) {
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) {
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) {
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", "未知规格")
if err := AssignSybShopeeSKU(db, "SYB-A", "SKU-B"); err == nil {
t.Fatal("不得把其他蝦皮商品的 SKU 保存到当前顺运宝明细")
}
if got := readOrderSKU(t, db, "SYB-A"); got != "" {
t.Fatalf("越权选择后不应写库,实际 %q", got)
}
if err := AssignSybShopeeSKU(db, "SYB-A", "SKU-A"); err != nil {
t.Fatalf("同商品 SKU 应保存成功: %v", err)
}
}
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>
+10 -11
View File
@@ -196,11 +196,10 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
| 订单号 | |
| 商品标题 | |
| 蝦皮商品 ID | 关联蝦皮数据模块 |
| 规格 SKU | 蝦皮的规格编号 |
| 数量 | |
| 价格 | 蝦皮售价,**台币分**,见 §7 |
| 图片 | 存 URL,表格里显示缩略图 |
| 处理阶段 | 未找到蝦皮商品 / 待确认蝦皮规格 / 未关联 PDD / 待采集 / 采集中 / 采集失败 / 规格待匹配 / 可采购 / 采购数据异常 / 已创建任务 |
| 处理阶段 | 采购数据异常(顺运宝未提供规格)/ 未关联 PDD / 待采集 / 采集中 / 采集失败 / 规格待匹配 / 可采购 / 已创建任务 |
| 下一步 | 每行只显示当前最需要完成的一个操作 |
| 更新时间 | |
@@ -208,15 +207,16 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
**双击行或点击下一步打开处理弹窗:**
- 顺运宝商品 ID 能找到蝦皮商品后,先确认蝦皮 SKU。规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时可以自动确认;零候选和多候选必须人工选择,不能猜。
- `shopee_sku_id` 只表示“蝦皮规格已确认”,不等于 PDD 规格已经匹配。完整处理阶段还要检查当前 PDD 关联、采集状态和 `sku_mappings`。
- 蝦皮 SKU 确认后,可在同一弹窗填写完整 PDD 链接并创建采集任务;链接关联规则与蝦皮详情完全一致。
- 顺运宝的 `product_spec` 是匹配主链路的真实来源;系统不再要求先确认蝦皮 SKU。
- “规格身份键” `spec_key` 只对顺运宝规格原文去除首尾空白、折叠连续空白,用于判断是否是同一条规格;颜色、尺寸和建议的解析只用于显示与推荐,不改变身份。
- 找到蝦皮商品后,可在同一弹窗填写完整 PDD 链接并创建采集任务;链接关联规则与蝦皮详情完全一致。
- 显示蝦皮规格原文和当前 PDD 商品 `skus_json` 里的动态规格组合与人民币价格;
- PDD 维度来自采集结果的 `dimensions`,不得写死成颜色、尺码两个维度;
- 操作员选好对应关系,点保存;
- `[必须]` 保存的是**可复用的 SKU 映射**,不是这一张订单的临时数据。
下次遇到同一个蝦皮 SKU 自动带出,操作员只需确认。
- `[必须]` 保存的是**可复用的商品规格映射**,键为
`(蝦皮商品 ID, spec_key, PDD 商品 ID)`,不是这一张订单的临时数据。
下次遇到同商品、同规格和同 PDD 商品时自动带出。
- `[必须]` 该商品尚未采集(`skus_json` 为空)时,弹窗直接展示关联和创建采集任务入口,
而不是显示一个空列表让人困惑。
@@ -333,7 +333,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
1. 该蝦皮商品已填 PDD 链接;
2. 当前 PDD 商品已采集成功(`skus_json` 非空);
3. 该蝦皮 SKU 已有 PDD 规格映射;
3. 该顺运宝规格非空,且已有当前 PDD 商品的规格映射;
4. 数量大于 0;
5. **价格上限已填且大于 0** —— 默认从当前映射的 PDD 规格价格带出,操作员可改,但不允许为空;
6. 已选择分配的客户端;
@@ -470,9 +470,8 @@ MVP 之后:
- **采集任务不指定客户端**,谁领到算谁的;采购任务仍可分配,也允许留空。
- 任务**分配给指定客户端**,Client 只领分给自己的。
- **不加心跳接口**;设置页显式登记,领取接口保留兼容登记,在线状态由最近活动时间派生。
- SKU 映射**独立成表且可复用**,同一蝦皮 SKU 只人工匹配一次。
- 货运单的"规格 SKU"**直接对应蝦皮 `商品規格ID`**,不需要转换。
但**不加外键**——本地查不到该 SKU 是常态,见 [03 数据模型](03-data-model.md) §4。
- 规格映射**独立成表且可复用**,按 `(蝦皮商品 ID, spec_key, PDD 商品 ID)` 隔离;换 PDD 商品不会误用旧映射,换回时可继续复用。
- 货运单的规格原文来自顺运宝;不再依赖蝦皮 `商品規格ID` 才能关联 PDD 和建立规格映射。
- Admin 不提供固定默认密码;第一次启动由用户创建第一个管理员,之后初始化入口永久关闭。
- 账号只有 `admin`(管理员)和 `purchaser`(采购员)两种固定角色;采购员不能管理用户。
- Web 登录只保护 HTML 页面,`/api/v1/client/*` 不使用网页登录 Session,现有 Client 行为保持不变。
+53 -47
View File
@@ -128,6 +128,12 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、
错误要等操作员点到那个页面才暴露,如果那是个写操作页面,暴露出来的就不是报错
而是写坏数据。自检只查表名,不逐列校验:够抓住"迁移没跑到"这一类问题,代价也低。
MySQL 迁移 v3(工单 #88)是追加式迁移:新增 `spec_mappings`,为 `syb_orders`
增加 `spec_key`,为 `shopee_products` 增加 `source`。迁移使用稳定 `syb_id`
游标分批回填,先完整备份旧 `sku_mappings` 为 `sku_mappings_v3_backup`,
再转换能找到顺运宝商品与规格的映射。复跑、字段已建但约束未建等中断状态必须自动收敛;
启动自检除表名外还校验 v3 主键、长度、二进制排序规则和 `source` 取值约束。
## 3. 蝦皮数据
蝦皮报表**一个文件里混了两层数据**,所以拆成两张表。
@@ -140,6 +146,7 @@ CREATE TABLE shopee_products (
title TEXT NOT NULL, -- 蝦皮「商品名稱」
shopee_status TEXT, -- 蝦皮「商品當前狀態」
main_sku_code TEXT, -- 蝦皮「主商品貨號」
source VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'report', -- report / syb
-- 下面两个是我们自己维护的,报表里没有,导入时绝不能覆盖
pdd_goods_url TEXT, -- ★ 人工填写的 PDD 链接原文
@@ -149,11 +156,18 @@ CREATE TABLE shopee_products (
updated_at TEXT NOT NULL
);
ALTER TABLE shopee_products ADD CONSTRAINT chk_shopee_products_source
CHECK (source IN ('report', 'syb'));
CREATE INDEX idx_shopee_products_pdd ON shopee_products(pdd_goods_id);
```
`[必须]` **采集结果和采集状态不在这张表里**,它们属于 PDD 商品,见 §4。
`source='syb'` 表示顺运宝先到、蝦皮报表尚未导入时创建的最小商品骨架。
后续 Excel 导入同一 `goods_id` 时必须补全商品信息并把来源提升为 `report`;
导入不得覆盖人工维护的 PDD 关联。
`pdd_goods_id` 表示"这个蝦皮商品**当前**对应哪个 PDD 商品"。
PDD 商品下架换代时改这里,是一个随时会变的关联,不是永久绑定。
@@ -299,7 +313,7 @@ CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);
**为什么用软删除**
`sku_mappings` 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。
`spec_mappings` 指向这张表。硬删会把人工攒了很久的匹配成果一起带走。
软删除后界面不再显示,但记录和映射都还在。
`[必须]` 操作员重新填同一个链接时**要能复活**(清 `deleted_at`、状态置回
@@ -434,9 +448,10 @@ CREATE TABLE syb_orders (
syb_id TEXT PRIMARY KEY, -- 货运单**明细行** ID(顺运宝 details[].id)
order_no TEXT NOT NULL, -- 订单号(顺运宝外层 code,不是 orderCode)
title TEXT, -- 商品标题
product_spec TEXT, -- 规格原文,v5 新增,对应 shopee_skus.spec_raw
product_spec TEXT, -- 顺运宝规格原文
spec_key VARCHAR(191), -- 规格身份键;空规格保持 NULL
shopee_goods_id TEXT, -- 蝦皮商品 ID(顺运宝 productId,11 位)
shopee_sku_id TEXT, -- 蝦皮规格 ID,人工/自动匹配的结果
shopee_sku_id TEXT, -- 历史兼容列;新主链路不读写
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_twd_cent INTEGER CHECK (price_twd_cent IS NULL OR price_twd_cent >= 0),
image_url TEXT, -- 存 URL,不存图片本身
@@ -453,24 +468,12 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
- `price_twd_cent` 是**台币分**,是蝦皮那边的售价,
和采购任务的人民币价格上限**没有换算关系**,不要互相赋值。
- `image_url` 存 URL。`[必须]` 不要把图片二进制存进数据库。
- `shopee_sku_id` **直接对应 `shopee_skus.sku_id`**,编号格式一致,不需要额外转换。
`[必须]` 但**不要加外键约束**。理由:蝦皮报表不是全量目录(样本里平均每商品仅 1.17 个 SKU),
货运单来了而本地查不到这个 SKU 是**常态**。加了外键,同步就会直接失败。
正确做法是软关联:查不到时照常保存货运单,界面上标出"SKU 未收录",
并引导操作员到蝦皮数据模块手动新增(见 [05 界面规范](05-ui-specification.md) §4.4)。
**"编号能对上"和"本地一定查得到"是两回事,别混。**
**处理阶段是派生的,不存字段**:`shopee_sku_id` 非空只表示蝦皮规格已确认。
PDD 规格是否已匹配要联查 `sku_mappings`,同时限定 `shopee_sku_id` 和当前
`pdd_goods_id`,并确认 `pdd_option_key` 仍存在于最新 `skus_json`;选项消失后按待匹配处理。
`[必须]` **`shopee_sku_id` 顺运宝同步绝不能覆盖**(工单 #46)。它是蝦皮规格识别
结果(人工确认或确定性唯一识别产生),顺运宝那边根本没有这个值(顺运宝只给商品级 `productId`,
不含蝦皮規格ID,见 §5.1 下方接口对照)。`repository.UpsertSybOrder` 的
`ON CONFLICT DO UPDATE SET` 里不出现这一列,新建行时才会写它(此时通常是空值)。
- `spec_key` 通过 `SpecKey(product_spec)` 计算:去除首尾空白,把连续空白折叠为一个半角空格,
但不做大小写、繁简、颜色或单位转换。空规格不生成键,并在采购流程中作为数据异常阻断。
- **处理阶段是派生的,不存字段**:PDD 规格是否已匹配要按
`(shopee_goods_id, spec_key, 当前 pdd_goods_id)` 联查 `spec_mappings`,并确认 `pdd_option_key`
仍存在于最新 `skus_json`。
- `shopee_sku_id` 只为兼容历史数据保留;顺运宝同步、弹窗、映射和建采购任务均不再读写它。
`[必须]` 一行对应顺运宝一张货运单的**一个商品明细**(`details[]` 的一项),
不是一张货运单——一张货运单可以有多个商品,各占一行,`syb_id` 用的是
@@ -518,33 +521,35 @@ CREATE TABLE syb_sync_state (
`[必须]` 本表不保存顺运宝 Cookie、token、密码、验证码、收件信息或原始响应。
失败原因最多保留 500 个字符,并在页面输出时由模板转义。
## 6. `sku_mappings` 规格映射
## 6. `spec_mappings` 顺运宝规格映射
"蝦皮的这个规格 = 拼多多的那个规格",**匹配一次,以后复用**。
"这个蝦皮商品的这条顺运宝规格 = 当前拼多多商品的那个规格",**匹配一次,以后复用**。
```sql
CREATE TABLE sku_mappings (
shopee_sku_id TEXT NOT NULL,
pdd_goods_id TEXT NOT NULL, -- ★ 这条映射属于哪个 PDD 商品
pdd_option_key TEXT NOT NULL, -- 规范化的组合键,见 §6.2
pdd_options TEXT NOT NULL, -- 原始 options 对象,显示用
goods_id TEXT NOT NULL, -- 蝦皮商品 ID,方便按商品批量查
mapped_at TEXT NOT NULL,
mapped_by TEXT,
PRIMARY KEY (shopee_sku_id, pdd_goods_id),
FOREIGN KEY (shopee_sku_id) REFERENCES shopee_skus(sku_id) ON DELETE CASCADE
CREATE TABLE 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, -- 见 §6.2
pdd_options LONGTEXT NOT NULL, -- 原始 options JSON,显示用
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)
);
CREATE INDEX idx_sku_mappings_goods ON sku_mappings(goods_id);
CREATE INDEX idx_sku_mappings_pdd ON sku_mappings(pdd_goods_id);
```
### 6.1 为什么主键要带上 `pdd_goods_id`
`[必须]` 不加外键。顺运宝明细可能比蝦皮报表更早到达,同步要先创建 skeleton 商品;
PDD 商品还可软删除,映射作为审计和可恢复数据必须保留。
### 6.1 为什么主键要带上商品和 `pdd_goods_id`
PDD 商品下架换代很频繁——A 买不到了就得换 B。
假设蝦皮商品 X 原来对应 PDD 商品 A,操作员匹配好了"黑色/M → 黑色/M码";
后来 A 下架,换成了 B。如果映射只按 `shopee_sku_id` 存,那条旧映射还在,
后来 A 下架,换成了 B。如果映射不带 `pdd_goods_id`,那条旧映射还在,
但它描述的是 **A 的规格**:
| | 后果 |
@@ -555,8 +560,9 @@ PDD 商品下架换代很频繁——A 买不到了就得换 B。
把 `pdd_goods_id` 放进主键后,`[必须]` 查映射**永远带上"当前对应的 PDD 商品"**:
```sql
SELECT ... FROM sku_mappings
WHERE shopee_sku_id = ?
SELECT ... FROM spec_mappings
WHERE shopee_goods_id = ?
AND spec_key = ?
AND pdd_goods_id = (蝦皮商品当前的 pdd_goods_id)
```
@@ -701,12 +707,12 @@ CREATE TABLE clients (
```text
shopee_products ──1:N──→ shopee_skus
│ │
│ pdd_goods_id │ shopee_sku_id
│ (当前对应哪个 ↓
│ PDD 商品,可换) sku_mappings ──pdd_goods_id──┐
↓ │
pdd_products ←────────────────────────────────────────┘
│
│ pdd_goods_id(当前 PDD 商品,可换)
↓
pdd_products ←─pdd_goods_id─ spec_mappings
↑
syb_orders ─(shopee_goods_id, spec_key)─┘
(skus_json 里是所有规格和价格)
syb_orders ──创建──→ tasks ──分配──→ clients
@@ -717,7 +723,7 @@ users(采购员)──1:N 当前归属──────────┘
两条关联都可以变,这是有意的:
- `shopee_products.pdd_goods_id`:PDD 商品下架换代时改
- `sku_mappings` 按 `(蝦皮SKU, PDD商品)` 存:换了商品自然查不到旧映射
- `spec_mappings` 按 `(蝦皮商品, 顺运宝规格键, PDD商品)` 存:换了商品自然查不到旧映射
## 11. 与 Client 数据模型的关系
+14 -12
View File
@@ -153,6 +153,7 @@
| ☐ | 勾选 |
| 商品 ID | |
| 商品名称 | 截断 + 悬停完整 |
| 数据来源 | 蝦皮报表 / 顺运宝补建;不只靠颜色区分 |
| 颜色 / 尺码 / 建议 | 解析失败的显示为 `—` 并**整行标黄**,提示需人工补 |
| PDD 链接 | 空的显示"**未填写**"并标红,这是最需要操作员注意的状态 |
| 采集状态 | 未填链接 / 未采集 / 采集中 / 已采集 / 采集失败。**数据来自两张表**,判断方式见 [01 需求](01-requirements.md) §6.1 |
@@ -197,6 +198,9 @@
### 4.4 手动新增
> 本节是旧 SKU 确认流程的历史要求,已被工单 #88 的顺运宝 `spec_key`
> 主链路取代;保留仅为说明蝦皮报表数据可继续独立维护,顺运宝处理不再以此为前置条件。
`[必须]` 必须提供"新增 SKU"入口。
原因:蝦皮报表只包含**有销售成绩的** SKU(样本里平均每个商品仅 1.17 个),
@@ -468,27 +472,25 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
### 6.2 表格列
☐ / 货运单明细ID / 订单号 / 商品标题 / 规格 / 蝦皮商品ID / 规格SKU / 数量 /
☐ / 货运单明细ID / 订单号 / 商品标题 / 规格 / 蝦皮商品ID / 数量 /
价格(台币)/ 图片 / **处理阶段** / **下一步** / 更新时间
- 一行对应顺运宝一张货运单的**一个商品明细**,不是一张货运单——一张货运单
可以有多个商品,各占一行。
- 图片显示小缩略图。`[必须]` 存 URL,不要把图片塞进数据库。
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分:
未找到蝦皮商品、待确认蝦皮规格、未关联 PDD、PDD 待采集、采集中、采集失败、
规格待匹配、可采购、采购数据异常和已创建任务。`shopee_sku_id` 非空只说明蝦皮规格已确认。
采购数据异常(顺运宝未提供规格)、未关联 PDD、PDD 待采集、采集中、
采集失败、规格待匹配、可采购和已创建任务。
- 每行显示一个有文字的“下一步”按钮;双击行和按钮打开同一个服务端渲染处理弹窗。
- 规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时,系统可以自动确认
蝦皮 SKU;零候选或多候选必须由采购员选择,不能模糊猜测。
- 规格原文为空时不显示匹配表单,明确提示“顺运宝未提供规格”,并禁止建立映射或创建采购任务。
- 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。
### 6.3 采购处理与规格匹配弹窗(双击行或点击下一步打开)
弹窗按当前处理阶段渐进展示。第一步先确认蝦皮 SKU;顺运宝同步本身仍不提供
`shopee_sku_id`,但同步写入明细后可以根据本地蝦皮数据做确定性唯一匹配。
后续 PDD 关联、采集和规格映射使用同一个弹窗继续完成。
弹窗按当前处理阶段渐进展示。它直接以顺运宝规格原文及其 `spec_key`
连接当前 PDD 商品选项,不再经过蝦皮 SKU 确认步骤。PDD 关联、采集和规格映射使用同一个弹窗完成。
确认蝦皮 SKU 后,弹窗先展示当前 PDD 商品 ID、完整链接和采集状态。未关联时
弹窗先展示当前 PDD 商品 ID、完整链接和采集状态。未关联时
可直接填写完整链接;换成其他商品需要明确确认。待采集或采集失败时显示
“创建采集任务”或“重新创建采集任务”,采集中和已采集状态只显示说明。
@@ -512,11 +514,11 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
- 右侧下拉的选项来自该商品 `skus_json` 里的 `dimensions`,
**不要写死"颜色/尺码"两个维度**——PDD 商品可能有第三个维度。
- **打开时先查 `sku_mappings`**,有记录就自动带出并提示"已自动带出"。
这是省人工的关键:同一个蝦皮 SKU 只需人工匹配一次。
- **打开时先按 `(蝦皮商品 ID, spec_key, 当前 PDD 商品 ID)` 查 `spec_mappings`**,
有记录就自动带出并提示"已自动带出"。
- 查询必须同时带当前 `pdd_goods_id`,并核对选项仍存在且可购买;换品或选项消失时回到待匹配。
- 该商品**还没采集**(`skus_json` 为空)时,不要显示空下拉,直接展示关联和创建采集任务入口。
- 保存写的是 `sku_mappings`(可复用),**不是这一张订单的临时数据**。
- 保存写的是 `spec_mappings`(可复用),**不是这一张订单的临时数据**。
### 6.4 创建采购任务