package service import ( "database/sql" "errors" "strings" "testing" "time" "cmautobuy/admin/model" "cmautobuy/admin/repository" ) func TestAssociateSybPdd_兼容商品ID并复用已有采集结果(t *testing.T) { db := newTestDB(t) seedWorkflowOrder(t, db, "SYB-PDD-ID", "SP-PDD-ID", "黑色,M") if _, err := repository.EnsurePddProduct(db, "737116531267", pddURLA); err != nil { t.Fatal(err) } if err := repository.SetCollectResult(db, "737116531267", "已采集商品", "已有店铺", collectedThreeDimensions); err != nil { t.Fatal(err) } goodsID, err := AssociateSybPdd(db, "SYB-PDD-ID", " 737116531267 ", false) if err != nil || goodsID != "737116531267" { t.Fatalf("纯商品 ID 关联结果 goodsID=%q err=%v", goodsID, err) } product, err := repository.GetPddProductByGoodsID(db, goodsID) if err != nil || product == nil { t.Fatalf("读取复用商品失败: product=%+v err=%v", product, err) } if product.URL != pddURLA || product.CollectStatus != model.CollectCollected || product.Title != "已采集商品" || product.SkusJSON != collectedThreeDimensions { t.Fatalf("已有采集结果不应被清空或重置: %+v", product) } var productCount, taskCount int db.QueryRow(`SELECT COUNT(*) FROM pdd_products WHERE goods_id = ?`, goodsID).Scan(&productCount) db.QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&taskCount) if productCount != 1 || taskCount != 0 { t.Fatalf("应复用一条商品且不自动建任务: products=%d tasks=%d", productCount, taskCount) } } func TestAssociateSybPdd_新商品ID生成标准链接且完整链接保持兼容(t *testing.T) { t.Run("纯商品ID", func(t *testing.T) { db := newTestDB(t) seedWorkflowOrder(t, db, "SYB-NEW-ID", "SP-NEW-ID", "白色,L") goodsID, err := AssociateSybPdd(db, "SYB-NEW-ID", "123456789", false) if err != nil || goodsID != "123456789" { t.Fatalf("关联结果 goodsID=%q err=%v", goodsID, err) } product, _ := repository.GetPddProductByGoodsID(db, goodsID) if product == nil || product.URL != canonicalPddGoodsURL+goodsID || product.CollectStatus != model.CollectPending { t.Fatalf("新商品 ID 没有生成待采集标准档案: %+v", product) } }) t.Run("完整链接", func(t *testing.T) { db := newTestDB(t) seedWorkflowOrder(t, db, "SYB-FULL-URL", "SP-FULL-URL", "白色,L") goodsID, err := AssociateSybPdd(db, "SYB-FULL-URL", pddURLA, false) if err != nil || goodsID != "737116531267" { t.Fatalf("原完整链接行为回归: goodsID=%q err=%v", goodsID, err) } }) } func TestAssociateSybPdd_商品ID输入边界和换品确认(t *testing.T) { for _, input := range []string{"12345", "1234567890123456789012345", "737116abc", "普通文本"} { t.Run(input, func(t *testing.T) { db := newTestDB(t) seedWorkflowOrder(t, db, "SYB-BAD-ID", "SP-BAD-ID", "黑色,M") if _, err := AssociateSybPdd(db, "SYB-BAD-ID", input, false); !errors.Is(err, ErrBadPddURL) { t.Fatalf("输入 %q 应返回 ErrBadPddURL,实际 %v", input, err) } }) } db := newTestDB(t) seedWorkflowOrder(t, db, "SYB-REPLACE", "SP-REPLACE", "黑色,M") if _, err := AssociateSybPdd(db, "SYB-REPLACE", "737116531267", false); err != nil { t.Fatal(err) } if _, err := AssociateSybPdd(db, "SYB-REPLACE", "937122477375", false); !errors.Is(err, ErrPddAssociationReplaceRequired) { t.Fatalf("纯 ID 换成其他商品仍应要求确认,实际 %v", err) } if _, err := AssociateSybPdd(db, "SYB-REPLACE", "937122477375", true); err != nil { t.Fatalf("确认后应允许更换: %v", err) } } const collectedThreeDimensions = `{ "goods_id":"737116531267", "price_granularity":"sku", "dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"},{"key":"style","name":"款式"}], "skus":[ {"options":{"style":"常规","size":"M码","color":"黑色"},"price_cent":3990,"available":true}, {"options":{"style":"加绒","size":"L码","color":"白色"},"price_cent":4590,"available":true}, {"options":{"style":"下架","size":"S码","color":"红色"},"price_cent":2990,"available":false} ] }` func seedPurchasableWorkflow(t *testing.T, db *sql.DB, sybID string) string { t.Helper() order := seedWorkflowOrder(t, db, sybID, "SP-1", "黑色,M") order.PriceTwdCent = 999900 if _, err := repository.UpsertSybOrder(db, order); err != nil { t.Fatal(err) } if _, err := AssociateShopeePdd(db, "SP-1", pddURLA, false); err != nil { t.Fatal(err) } if err := repository.SetCollectResult(db, "737116531267", "PDD 上衣", "店铺", collectedThreeDimensions); err != nil { t.Fatal(err) } key, _ := OptionKey(map[string]string{"color": "黑色", "size": "M码", "style": "常规"}) return key } func TestSybMapping_动态维度保存并复用(t *testing.T) { db := newTestDB(t) key := seedPurchasableWorkflow(t, db, "SYB-1") detail, err := GetSybProcessingDetail(db, "SYB-1") if err != nil { t.Fatal(err) } if len(detail.PddDimensionNames) != 3 || detail.PddDimensionNames[2] != "款式" || len(detail.PddChoices) != 2 { t.Fatalf("动态维度/可用组合不对: names=%v choices=%+v", detail.PddDimensionNames, detail.PddChoices) } if err := SaveSybMapping(db, "SYB-1", key, "USR-1"); err != nil { t.Fatal(err) } detail, err = GetSybProcessingDetail(db, "SYB-1") if err != nil { t.Fatal(err) } if !detail.MappingValid { t.Fatal("保存后相同蝦皮商品规格 + 当前 PDD 商品应自动复用") } list, err := ListSybOrdersView(db, "", SybStagePurchaseReady, 1) if err != nil { t.Fatal(err) } if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultMaxPrice != "39.90" { t.Fatalf("可采购阶段不对: %+v", list) } } 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) key := seedPurchasableWorkflow(t, db, "SYB-1") SaveSybMapping(db, "SYB-1", key, "USR-1") if _, err := AssociateShopeePdd(db, "SP-1", pddURLB, true); err != nil { t.Fatal(err) } repository.SetCollectResult(db, "937122477375", "另一个商品", "店铺", stringsReplaceGoodsID(collectedThreeDimensions)) detail, _ := GetSybProcessingDetail(db, "SYB-1") 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) { db := newTestDB(t) key := seedPurchasableWorkflow(t, db, "SYB-1") SaveSybMapping(db, "SYB-1", key, "USR-1") changed := `{"goods_id":"737116531267","dimensions":[{"key":"color","name":"颜色"}],"skus":[{"options":{"color":"白色"},"price_cent":1000,"available":true}]}` repository.SetCollectResult(db, "737116531267", "PDD 上衣", "店铺", changed) detail, _ := GetSybProcessingDetail(db, "SYB-1") if detail.MappingValid || detail.Stage != SybStageMappingPending { t.Fatalf("失效映射应回到待匹配: valid=%t stage=%s", detail.MappingValid, detail.Stage) } }) } func TestSybMapping_建议审计追加陈旧保护与事务回滚(t *testing.T) { db := newTestDB(t) order := seedWorkflowOrder(t, db, "SYB-AUDIT", "SP-AUDIT", "灰色,L建議53-57公斤") if _, err := AssociateShopeePdd(db, "SP-AUDIT", pddURLA, false); err != nil { t.Fatal(err) } raw := `{"goods_id":"737116531267","dimensions":[{"key":"color","name":"颜色分类"},{"key":"size","name":"尺码"}],"skus":[{"options":{"color":"灰色中长款","size":"L(80-115斤)"},"price_cent":3990,"available":true},{"options":{"color":"黑色","size":"L(80-115斤)"},"price_cent":3990,"available":true}]}` if err := repository.SetCollectResult(db, "737116531267", "PDD", "", raw); err != nil { t.Fatal(err) } detail, err := GetSybProcessingDetail(db, order.SybID) if err != nil { t.Fatal(err) } var suggested, other string for _, c := range detail.PddChoices { if c.Recommended { suggested = c.Key } else { other = c.Key } } if suggested == "" || detail.ContextVersion == "" { t.Fatalf("无建议: %+v", detail) } var unopenedMappings, unopenedDecisions int db.QueryRow(`SELECT COUNT(*) FROM spec_mappings WHERE shopee_goods_id='SP-AUDIT'`).Scan(&unopenedMappings) db.QueryRow(`SELECT COUNT(*) FROM spec_mapping_decisions WHERE shopee_goods_id='SP-AUDIT'`).Scan(&unopenedDecisions) if unopenedMappings != 0 || unopenedDecisions != 0 { t.Fatal("只打开或关闭弹窗不得写库") } if err := SaveSybMapping(db, order.SybID, other, "USR", detail.ContextVersion); err != nil { t.Fatal(err) } if err := SaveSybMapping(db, order.SybID, suggested, "USR", mappingContextVersionMust(t, db, order.SybID)); err != nil { t.Fatal(err) } var total, accepted int db.QueryRow(`SELECT COUNT(*),SUM(accepted) FROM spec_mapping_decisions WHERE shopee_goods_id='SP-AUDIT'`).Scan(&total, &accepted) if total != 2 || accepted != 1 { t.Fatalf("审计 total=%d accepted=%d", total, accepted) } old := mappingContextVersionMust(t, db, order.SybID) if err := repository.SetCollectResult(db, "737116531267", "PDD2", "", raw+" "); err != nil { t.Fatal(err) } if err := SaveSybMapping(db, order.SybID, suggested, "USR", old); err == nil { t.Fatal("陈旧弹窗必须拒绝") } before := suggested mustExecService(t, db, `DROP TABLE spec_mapping_decisions`) if err := SaveSybMapping(db, order.SybID, other, "USR", mappingContextVersionMust(t, db, order.SybID)); err == nil { t.Fatal("审计失败必须回滚") } var current string db.QueryRow(`SELECT pdd_option_key FROM spec_mappings WHERE shopee_goods_id='SP-AUDIT'`).Scan(¤t) if current != before { t.Fatal("映射未回滚") } } func mappingContextVersionMust(t *testing.T, db *sql.DB, id string) string { t.Helper() c, e := repository.GetSybOrderContext(db, id) if e != nil { t.Fatal(e) } return mappingContextVersion(*c) } func mustExecService(t *testing.T, db *sql.DB, q string) { t.Helper() if _, e := db.Exec(q); e != nil { t.Fatal(e) } } func stringsReplaceGoodsID(raw string) string { return `{"goods_id":"937122477375","price_granularity":"sku","dimensions":[{"key":"color","name":"颜色"},{"key":"size","name":"尺码"},{"key":"style","name":"款式"}],"skus":[{"options":{"style":"常规","size":"M码","color":"黑色"},"price_cent":3990,"available":true}]}` } func TestCreatePurchaseTasks_安全字段和重复保护(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) } admin, _, _ := prepareClientAssignmentUsers(t, db) if err := RegisterClient(db, model.Client{ClientID: "CLIENT-1", Name: "采购机"}, true); err != nil { t.Fatal(err) } result, err := CreatePurchaseTasks(db, admin, []PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 4200}}, "CLIENT-1") if err != nil || result.Created != 1 || len(result.Failures) != 0 { t.Fatalf("创建结果=%+v err=%v", result, err) } var task model.Task var status string err = db.QueryRow(`SELECT status, assigned_client, syb_id, order_no, goods_id, shopee_sku_id, pdd_goods_url, pdd_goods_id, pdd_options, quantity, max_price_cent FROM tasks WHERE task_type='purchase' AND syb_id='SYB-1'`).Scan( &status, &task.AssignedClient, &task.SybID, &task.OrderNo, &task.GoodsID, &task.ShopeeSKUID, &task.PddGoodsURL, &task.PddGoodsID, &task.PddOptions, &task.Quantity, &task.MaxPriceCent) if err != nil { t.Fatal(err) } if status != "assigned" || task.MaxPriceCent != 4200 || task.MaxPriceCent == 999900 || task.Quantity != 1 || task.PddOptions != key || task.AssignedClient != "CLIENT-1" { t.Fatalf("采购任务字段不安全或不完整: status=%s task=%+v", status, task) } claimed, err := repository.ClaimNextTask(db, "CLIENT-1", []string{"purchase"}) if err != nil || claimed == nil || claimed.PddOptions != key || claimed.Quantity != 1 || claimed.MaxPriceCent != 4200 { t.Fatalf("现有 Client 领取契约读不到完整采购参数: task=%+v err=%v", claimed, err) } second, err := CreatePurchaseTasks(db, admin, []PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 4300}}, "CLIENT-1") if err != nil || second.Created != 0 || len(second.Failures) != 1 { t.Fatalf("重复提交应逐条拒绝: result=%+v err=%v", second, err) } } func TestCreatePurchaseTasks_客户端权限和逐条失败(t *testing.T) { db := newTestDB(t) key := seedPurchasableWorkflow(t, db, "SYB-1") SaveSybMapping(db, "SYB-1", key, "USR-1") admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db) RegisterClient(db, model.Client{ClientID: "CLIENT-A"}, true) AssignClient(db, admin, "CLIENT-A", buyerA.UserID, time.Now()) if _, err := CreatePurchaseTasks(db, buyerB, []PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 4200}}, "CLIENT-A"); err == nil { t.Fatal("采购员不得使用其他采购员的客户端") } result, err := CreatePurchaseTasks(db, buyerA, []PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 0}, {SybID: "NOT-FOUND", MaxPriceCent: 100}}, "CLIENT-A") if err != nil || result.Created != 0 || len(result.Failures) != 2 { t.Fatalf("业务失败应逐条返回且不创建: result=%+v err=%v", result, err) } } func TestCreatePurchaseTasks_部分业务失败不影响有效明细(t *testing.T) { db := newTestDB(t) key := seedPurchasableWorkflow(t, db, "SYB-1") SaveSybMapping(db, "SYB-1", key, "USR-1") admin, _, _ := prepareClientAssignmentUsers(t, db) RegisterClient(db, model.Client{ClientID: "CLIENT-1"}, true) result, err := CreatePurchaseTasks(db, admin, []PurchaseTaskRequest{ {SybID: "NOT-FOUND", MaxPriceCent: 100}, {SybID: "SYB-1", MaxPriceCent: 4200}, }, "CLIENT-1") if err != nil || result.Created != 1 || len(result.Failures) != 1 || result.Failures[0].SybID != "NOT-FOUND" { t.Fatalf("部分失败边界不对: result=%+v err=%v", result, err) } } func TestCreatePurchaseTasksWithOptions_正常采购员可创建真实任务(t *testing.T) { db := newTestDB(t) key := seedPurchasableWorkflow(t, db, "SYB-LIVE") SaveSybMapping(db, "SYB-LIVE", key, "USR-1") admin, buyer, _ := prepareClientAssignmentUsers(t, db) if err := RegisterClient(db, model.Client{ClientID: "CLIENT-LIVE", Capabilities: `{"purchase_mode":"live"}`}, true); err != nil { t.Fatal(err) } if _, _, err := AssignClient(db, admin, "CLIENT-LIVE", buyer.UserID, time.Now()); err != nil { t.Fatal(err) } result, err := CreatePurchaseTasksWithOptions(db, buyer, []PurchaseTaskRequest{{SybID: "SYB-LIVE", MaxPriceCent: 4200}}, PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive, LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation}) if err != nil || result.Created != 1 { t.Fatalf("正常采购员创建真实任务失败: result=%+v err=%v", result, err) } var mode, confirmedBy string var confirmedAt sql.NullString if err := db.QueryRow(`SELECT execution_mode,live_confirmed_by,live_confirmed_at FROM tasks WHERE syb_id='SYB-LIVE'`).Scan(&mode, &confirmedBy, &confirmedAt); err != nil { t.Fatal(err) } if mode != "live" || confirmedBy != buyer.UserID || !confirmedAt.Valid { t.Fatalf("真实任务审计不完整: mode=%s by=%s at=%+v", mode, confirmedBy, confirmedAt) } } func TestCreatePurchaseTasksWithOptions_真实模式安全门禁(t *testing.T) { db := newTestDB(t) key := seedPurchasableWorkflow(t, db, "SYB-GATE") SaveSybMapping(db, "SYB-GATE", key, "USR-1") admin, _, _ := prepareClientAssignmentUsers(t, db) RegisterClient(db, model.Client{ClientID: "CLIENT-DRY", Capabilities: `{"purchase_mode":"dry_run"}`}, true) request := []PurchaseTaskRequest{{SybID: "SYB-GATE", MaxPriceCent: 4200}} if _, err := CreatePurchaseTasksWithOptions(db, admin, request, PurchaseTaskOptions{ ClientID: "CLIENT-DRY", ExecutionMode: model.TaskExecutionLive, LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation, }); err == nil || !strings.Contains(err.Error(), "未声明 live") { t.Fatalf("dry_run 客户端必须被拒绝: %v", err) } if _, err := CreatePurchaseTasksWithOptions(db, admin, request, PurchaseTaskOptions{ ClientID: "CLIENT-DRY", ExecutionMode: model.TaskExecutionLive, LiveAcknowledged: true, LiveConfirmation: "错误短语", }); err == nil || !strings.Contains(err.Error(), "必须勾选") { t.Fatalf("错误确认短语必须被拒绝: %v", err) } disabled := *admin disabled.Status = model.UserDisabled if _, err := CreatePurchaseTasks(db, &disabled, request, "CLIENT-DRY"); err == nil || !strings.Contains(err.Error(), "不是正常状态") { t.Fatalf("禁用账号必须被拒绝: %v", err) } } func TestParsePriceYuanToCent_精确转换(t *testing.T) { for raw, want := range map[string]int64{"39.90": 3990, "1": 100, "0.01": 1, "12.3": 1230} { got, err := ParsePriceYuanToCent(raw) if err != nil || got != want { t.Fatalf("%s => %d, %v; want %d", raw, got, err, want) } } for _, raw := range []string{"", "0", "-1", "1.234", "abc"} { if _, err := ParsePriceYuanToCent(raw); err == nil { t.Fatalf("应拒绝 %q", raw) } } }