543 lines
22 KiB
Go
543 lines
22 KiB
Go
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)
|
||
}
|
||
row := list.Rows[0]
|
||
if row.MappedPddChoice != "颜色:黑色 / 尺码:M码 / 款式:常规" ||
|
||
strings.Contains(row.MappedPddChoice, "白色") || row.MappingOptionKey != key || row.ContextVersion == "" {
|
||
t.Fatalf("采购确认只能带出当前映射组合,实际 %+v", row)
|
||
}
|
||
}
|
||
|
||
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, taskID string
|
||
err = db.QueryRow(`SELECT task_id, 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(
|
||
&taskID, &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 taskID != "cg1" || 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) {
|
||
cases := []struct {
|
||
status model.TaskStatus
|
||
wantStage string
|
||
wantReason string
|
||
wantAttention bool
|
||
}{
|
||
{model.TaskSucceeded, SybStagePurchaseCompleted, "已有成功采购任务", false},
|
||
{model.TaskManualReview, SybStagePurchaseReview, "待人工核对", true},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(string(tc.status), func(t *testing.T) {
|
||
db := newTestDB(t)
|
||
key := seedPurchasableWorkflow(t, db, "SYB-LOCKED")
|
||
if err := SaveSybMapping(db, "SYB-LOCKED", 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)
|
||
}
|
||
created, err := CreatePurchaseTasks(db, admin,
|
||
[]PurchaseTaskRequest{{SybID: "SYB-LOCKED", MaxPriceCent: 4200}}, "CLIENT-1")
|
||
if err != nil || created.Created != 1 {
|
||
t.Fatalf("准备采购任务失败: result=%+v err=%v", created, err)
|
||
}
|
||
if _, err := db.Exec(`UPDATE tasks SET status=? WHERE task_type='purchase' AND syb_id=?`, tc.status, "SYB-LOCKED"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
before := countPurchaseTasksForSyb(t, db, "SYB-LOCKED")
|
||
result, err := CreatePurchaseTasks(db, admin,
|
||
[]PurchaseTaskRequest{{SybID: "SYB-LOCKED", MaxPriceCent: 4300}}, "CLIENT-1")
|
||
if err != nil || result.Created != 0 || len(result.Failures) != 1 ||
|
||
!strings.Contains(result.Failures[0].Reason, tc.wantReason) {
|
||
t.Fatalf("%s 任务必须阻止重复采购: result=%+v err=%v", tc.status, result, err)
|
||
}
|
||
if after := countPurchaseTasksForSyb(t, db, "SYB-LOCKED"); after != before {
|
||
t.Fatalf("拒绝后任务数量从 %d 变为 %d", before, after)
|
||
}
|
||
list, err := ListSybOrdersView(db, "", "", tc.wantStage, 1)
|
||
if err != nil || list.Total != 1 || len(list.Rows) != 1 ||
|
||
list.Rows[0].Stage != tc.wantStage || list.Rows[0].NeedsAttention != tc.wantAttention {
|
||
t.Fatalf("%s 阶段筛选错误: result=%+v err=%v", tc.status, list, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func countPurchaseTasksForSyb(t *testing.T, db *sql.DB, sybID string) int {
|
||
t.Helper()
|
||
var count int
|
||
if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='purchase' AND syb_id=?`, sybID).Scan(&count); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return count
|
||
}
|
||
|
||
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})
|
||
if err != nil || result.Created != 1 {
|
||
t.Fatalf("正常采购员创建真实任务失败: result=%+v err=%v", result, err)
|
||
}
|
||
var mode, confirmedBy, createdBy string
|
||
var confirmedAt sql.NullString
|
||
if err := db.QueryRow(`SELECT execution_mode,live_confirmed_by,live_confirmed_at,created_by_user_id FROM tasks WHERE syb_id='SYB-LIVE'`).Scan(&mode, &confirmedBy, &confirmedAt, &createdBy); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if mode != "live" || confirmedBy != buyer.UserID || !confirmedAt.Valid {
|
||
t.Fatalf("真实任务审计不完整: mode=%s by=%s at=%+v", mode, confirmedBy, confirmedAt)
|
||
}
|
||
if createdBy != buyer.UserID {
|
||
t.Fatalf("采购任务创建人=%q,期望 %q", createdBy, buyer.UserID)
|
||
}
|
||
}
|
||
|
||
func TestCreatePurchaseTasksWithOptions_真实模式不要求Client预先声明Live(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}}
|
||
|
||
result, err := CreatePurchaseTasksWithOptions(db, admin, request, PurchaseTaskOptions{
|
||
ClientID: "CLIENT-DRY", ExecutionMode: model.TaskExecutionLive,
|
||
})
|
||
if err != nil || result.Created != 1 {
|
||
t.Fatalf("当前账号可见客户端不应被历史能力状态阻止: result=%+v err=%v", result, 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 TestCreatePurchaseTasksWithOptions_离线Live客户端可提前指派(t *testing.T) {
|
||
db := newTestDB(t)
|
||
key := seedPurchasableWorkflow(t, db, "SYB-OFFLINE")
|
||
SaveSybMapping(db, "SYB-OFFLINE", key, "USR-1")
|
||
admin, _, _ := prepareClientAssignmentUsers(t, db)
|
||
RegisterClient(db, model.Client{ClientID: "CLIENT-OFFLINE", Capabilities: `{"purchase_mode":"live"}`}, true)
|
||
if _, err := db.Exec(`UPDATE clients SET last_seen_at=? WHERE client_id=?`, "2026-08-10T08:00:00Z", "CLIENT-OFFLINE"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
result, err := CreatePurchaseTasksWithOptions(db, admin,
|
||
[]PurchaseTaskRequest{{SybID: "SYB-OFFLINE", MaxPriceCent: 4200}},
|
||
PurchaseTaskOptions{ClientID: "CLIENT-OFFLINE", ExecutionMode: model.TaskExecutionLive})
|
||
if err != nil || result.Created != 1 {
|
||
t.Fatalf("离线 live 客户端应可提前指派: result=%+v err=%v", result, err)
|
||
}
|
||
}
|
||
|
||
func TestCreatePurchaseTasksWithOptions_拒绝弹窗打开后变化的规格映射(t *testing.T) {
|
||
db := newTestDB(t)
|
||
blackKey := seedPurchasableWorkflow(t, db, "SYB-STALE")
|
||
SaveSybMapping(db, "SYB-STALE", blackKey, "USR-1")
|
||
context, err := repository.GetSybOrderContext(db, "SYB-STALE")
|
||
if err != nil || context == nil {
|
||
t.Fatal(err)
|
||
}
|
||
oldVersion := mappingContextVersion(*context)
|
||
whiteKey, _ := OptionKey(map[string]string{"color": "白色", "size": "L码", "style": "加绒"})
|
||
SaveSybMapping(db, "SYB-STALE", whiteKey, "USR-2")
|
||
admin, _, _ := prepareClientAssignmentUsers(t, db)
|
||
RegisterClient(db, model.Client{ClientID: "CLIENT-LIVE", Capabilities: `{"purchase_mode":"live"}`}, true)
|
||
|
||
result, err := CreatePurchaseTasksWithOptions(db, admin,
|
||
[]PurchaseTaskRequest{{SybID: "SYB-STALE", MaxPriceCent: 3990,
|
||
MappingOptionKey: blackKey, ContextVersion: oldVersion}},
|
||
PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive})
|
||
if err != nil || result.Created != 0 || len(result.Failures) != 1 ||
|
||
!strings.Contains(result.Failures[0].Reason, "已变化") {
|
||
t.Fatalf("旧弹窗必须因映射变化被拒绝: result=%+v err=%v", result, 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)
|
||
}
|
||
}
|
||
}
|