From d1165b9e175bbff6c74b6822c44837374064f2d0 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 12:24:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E9=A1=BA=E8=BF=90?= =?UTF-8?q?=E5=AE=9D=E8=A7=84=E6=A0=BC=E4=B8=BB=E9=93=BE=E8=B7=AF=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/others.go | 22 +- admin/handler/web/web.go | 1 - admin/main_test.go | 26 +- admin/model/model.go | 31 +- admin/repository/mapping.go | 38 +- admin/repository/mysql_db.go | 353 +++++++++++++++++- admin/repository/mysql_db_integration_test.go | 175 +++++++++ admin/repository/pdd.go | 4 +- admin/repository/shopee.go | 19 +- admin/repository/syb.go | 106 +++--- admin/repository/syb_test.go | 19 + admin/service/pdd.go | 2 +- admin/service/pdd_page_test.go | 2 +- admin/service/pdd_test.go | 72 ++-- admin/service/purchase_workflow.go | 25 +- admin/service/purchase_workflow_test.go | 45 ++- admin/service/shopee_list.go | 9 + admin/service/shopee_pdd.go | 7 +- admin/service/syb.go | 149 +------- admin/service/syb_workflow_test.go | 159 ++++---- admin/spec/speckey.go | 26 ++ admin/spec/speckey_test.go | 35 ++ admin/templates/shopee/list.html | 4 +- admin/templates/syb/detail_modal.html | 36 +- admin/templates/syb/list.html | 4 +- docs/admin/01-requirements.md | 21 +- docs/admin/03-data-model.md | 100 ++--- docs/admin/05-ui-specification.md | 26 +- 28 files changed, 985 insertions(+), 531 deletions(-) create mode 100644 admin/spec/speckey.go create mode 100644 admin/spec/speckey_test.go diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 367227d..31a1321 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -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. 已选择分配的客户端 diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index c5d4781..1ba9000 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -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) diff --git a/admin/main_test.go b/admin/main_test.go index fd5aef2..a7afecf 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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() diff --git a/admin/model/model.go b/admin/model/model.go index e07d322..c3fc8ef 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -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 } // ---------- 任务 ---------- diff --git a/admin/repository/mapping.go b/admin/repository/mapping.go index d2b36d5..d9b7874 100644 --- a/admin/repository/mapping.go +++ b/admin/repository/mapping.go @@ -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) } diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 1571078..9798782 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -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 +} diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index 050b6c4..f36167f 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -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 diff --git a/admin/repository/pdd.go b/admin/repository/pdd.go index 25b39ad..702d87f 100644 --- a/admin/repository/pdd.go +++ b/admin/repository/pdd.go @@ -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 { diff --git a/admin/repository/shopee.go b/admin/repository/shopee.go index c005ec6..8e41aff 100644 --- a/admin/repository/shopee.go +++ b/admin/repository/shopee.go @@ -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 diff --git a/admin/repository/syb.go b/admin/repository/syb.go index 18957ba..0a9e477 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -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) diff --git a/admin/repository/syb_test.go b/admin/repository/syb_test.go index 87a5ebc..f2f8081 100644 --- a/admin/repository/syb_test.go +++ b/admin/repository/syb_test.go @@ -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) diff --git a/admin/service/pdd.go b/admin/service/pdd.go index 30ab742..ac05c13 100644 --- a/admin/service/pdd.go +++ b/admin/service/pdd.go @@ -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) diff --git a/admin/service/pdd_page_test.go b/admin/service/pdd_page_test.go index 6be3bc6..c4bc0b6 100644 --- a/admin/service/pdd_page_test.go +++ b/admin/service/pdd_page_test.go @@ -228,7 +228,7 @@ func TestUpdatePddProductURL_同一个商品可以改写法(t *testing.T) { } } -// 换成另一个商品必须拒绝:这一行上挂着采集结果和 sku_mappings, +// 换成另一个商品必须拒绝:这一行上挂着采集结果和 spec_mappings, // goods_id 一换那些数据就全指到错的商品上,之后按它下单就是买错东西。 func TestUpdatePddProductURL_换成别的商品要拒绝(t *testing.T) { db := newTestDB(t) diff --git a/admin/service/pdd_test.go b/admin/service/pdd_test.go index 7d75fcd..f422405 100644 --- a/admin/service/pdd_test.go +++ b/admin/service/pdd_test.go @@ -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 商品的映射不该相同") } diff --git a/admin/service/purchase_workflow.go b/admin/service/purchase_workflow.go index b46ca7a..305c043 100644 --- a/admin/service/purchase_workflow.go +++ b/admin/service/purchase_workflow.go @@ -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 diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index 43f81cf..e2e5e93 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -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) { diff --git a/admin/service/shopee_list.go b/admin/service/shopee_list.go index 4cbdf5f..0c8ca2d 100644 --- a/admin/service/shopee_list.go +++ b/admin/service/shopee_list.go @@ -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 是状态筛选下拉框每个取值对应的中文文字, diff --git a/admin/service/shopee_pdd.go b/admin/service/shopee_pdd.go index 44a3acb..d3df9e4 100644 --- a/admin/service/shopee_pdd.go +++ b/admin/service/shopee_pdd.go @@ -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) } diff --git a/admin/service/syb.go b/admin/service/syb.go index ba95174..c3f6d93 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -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 } diff --git a/admin/service/syb_workflow_test.go b/admin/service/syb_workflow_test.go index 8f85729..e37e30e 100644 --- a/admin/service/syb_workflow_test.go +++ b/admin/service/syb_workflow_test.go @@ -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) + } + } +} diff --git a/admin/spec/speckey.go b/admin/spec/speckey.go new file mode 100644 index 0000000..1352ccc --- /dev/null +++ b/admin/spec/speckey.go @@ -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 +} diff --git a/admin/spec/speckey_test.go b/admin/spec/speckey_test.go new file mode 100644 index 0000000..288bfda --- /dev/null +++ b/admin/spec/speckey_test.go @@ -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("超长规格必须报错") + } +} diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html index e5a29bd..1d7bac8 100644 --- a/admin/templates/shopee/list.html +++ b/admin/templates/shopee/list.html @@ -51,6 +51,7 @@ 聚合成商品级之后,"部分失败"的行光看数字是正常的, 不加这一列坏数据就会被悄悄吃掉,见 #41。 */}} 待补 + 来源 PDD 链接 采集状态 更新时间 @@ -69,13 +70,14 @@ {{.SizeCount}} {{.SKUCount}} {{.PendingCount}} + {{.SourceText}} {{.PddURL}} {{.StatusText}} {{.UpdatedAt}} {{else}} - + {{if .IsFiltered}} 没有匹配的数据,换个商品 ID 或商品名称试试。 {{else}} diff --git a/admin/templates/syb/detail_modal.html b/admin/templates/syb/detail_modal.html index 4eef514..6f1ce47 100644 --- a/admin/templates/syb/detail_modal.html +++ b/admin/templates/syb/detail_modal.html @@ -11,39 +11,13 @@
明细 ID
{{.SybID}}
商品
{{.Title}}
-
顺运宝规格
{{.ProductSpec}}
+
顺运宝规格
{{if .ProductSpec}}{{.ProductSpec}}{{else}}—{{end}}
数量
{{.Quantity}}
蝦皮商品 ID
{{.ShopeeGoodsID}}
-
蝦皮规格 ID
{{if .ShopeeSKUID}}{{.ShopeeSKUID}}{{else}}—{{end}}
- {{if not .ShopeeExists}} -

蝦皮数据中找不到商品 {{.ShopeeGoodsID}}。请先导入该商品,或核对顺运宝商品链接提取出的 ID。

-

到蝦皮数据查看

- {{else if and (not .ShopeeSKUID) (not .Candidates)}} -

这个蝦皮商品没有可选 SKU。请先在蝦皮数据中补充规格。

-

到蝦皮数据补充

- {{else if not .ShopeeSKUID}} -
- - - - - -
- - - 系统只会自动采用唯一精确候选;这里的选择仍会由服务端校验是否属于当前商品。 -
-
- -
-
+ {{if eq .Stage "spec_missing"}} + {{else}}

关联采购商品

@@ -87,14 +61,14 @@
- + - PDD 维度来自采集结果:{{range $i, $name := .PddDimensionNames}}{{if $i}} / {{end}}{{$name}}{{end}}。保存后相同蝦皮 SKU + 当前 PDD 商品会复用。 + PDD 维度来自采集结果:{{range $i, $name := .PddDimensionNames}}{{if $i}} / {{end}}{{$name}}{{end}}。保存后相同蝦皮商品 + 顺运宝规格 + 当前 PDD 商品会复用。
{{if and (not .MappingValid) .PddChoices}}

尚无有效映射,或原映射已不在最新采集结果中,请重新选择。

{{end}}
diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index 385250c..8ab798f 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -91,7 +91,6 @@ 商品标题 规格 蝦皮商品 ID - 规格 SKU 数量 价格 图片 @@ -112,7 +111,6 @@ {{.Title}} {{.ProductSpec}} {{.ShopeeGoodsID}} - {{if .ShopeeSKUID}}{{.ShopeeSKUID}}{{else}}—{{end}} {{.Quantity}} {{.PriceText}} @@ -124,7 +122,7 @@ {{else}} - + {{if .IsFiltered}} 当前筛选条件下没有货运单明细。
换个订单号或清空搜索词再试。查看全部 diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index 1166073..dda7750 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -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 行为保持不变。 diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 9f92724..13efb33 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -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 数据模型的关系 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 0f841c5..b87509c 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -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 创建采购任务