Files
cmautobuy/admin/service/purchase_workflow_test.go
T

626 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"database/sql"
"errors"
"math"
"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 TestUpsertSybOrder_同步后自动复用蝦皮商品Pdd关联(t *testing.T) {
db := newTestDB(t)
if err := repository.UpsertShopeeProduct(db, "SP-REUSE", "目录商品", "正常", "MAIN-1"); err != nil {
t.Fatal(err)
}
if _, err := AssociateShopeePdd(db, "SP-REUSE", pddURLA, false); err != nil {
t.Fatal(err)
}
seedWorkflowOrder(t, db, "SYB-REUSE", "SP-REUSE", "黑色,M")
detail, err := GetSybProcessingDetail(db, "SYB-REUSE")
if err != nil || detail == nil {
t.Fatalf("读取同步明细失败: detail=%+v err=%v", detail, err)
}
if detail.Stage != SybStagePddPending || detail.PddGoodsID != "737116531267" || detail.PddAssociationText == "" {
t.Fatalf("新明细应直接复用已有 PDD 关联并进入待采集阶段: %+v", detail)
}
list, err := ListSybOrdersView(db, "", "", SybStagePddPending, 1)
if err != nil || list.Total != 1 || list.Rows[0].PddAssociationText == "" {
t.Fatalf("列表应显示关联来源: list=%+v err=%v", list, err)
}
// 重复同步仍复用同一商品关系,不覆盖主数据上的 PDD 关联。
seedWorkflowOrder(t, db, "SYB-REUSE", "SP-REUSE", "黑色,M")
product, err := repository.GetShopeeProductByGoodsID(db, "SP-REUSE")
if err != nil || product == nil || product.PddGoodsID != "737116531267" {
t.Fatalf("重复同步覆盖了蝦皮商品的 PDD 关联: product=%+v err=%v", product, err)
}
if err := repository.SoftDeletePddProduct(db, "737116531267"); err != nil {
t.Fatal(err)
}
detail, err = GetSybProcessingDetail(db, "SYB-REUSE")
if err != nil || detail.Stage != SybStagePddMissing || detail.PddAssociationText != "" {
t.Fatalf("PDD 软删除后必须回到关联失效状态且不显示复用提示: detail=%+v err=%v", detail, err)
}
}
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 TestGetSybOrderView_按明细ID准备单条采购确认(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-DEEP-LINK")
if err := SaveSybMapping(db, "SYB-DEEP-LINK", key, "USR-1"); err != nil {
t.Fatal(err)
}
view, err := GetSybOrderView(db, " SYB-DEEP-LINK ")
if err != nil || view == nil {
t.Fatalf("精确读取失败: view=%+v err=%v", view, err)
}
if !view.CanPurchase || view.SybID != "SYB-DEEP-LINK" ||
view.MappingOptionKey != key || view.DefaultUnitPrice != "39.90" {
t.Fatalf("深链接采购确认数据不完整: %+v", view)
}
missing, err := GetSybOrderView(db, "NOT-FOUND")
if err != nil || missing != nil {
t.Fatalf("不存在明细应返回 nil: view=%+v err=%v", missing, err)
}
}
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, "ORDER-SYB-1\nNOT-FOUND", "", SybStagePurchaseReady, 1)
if err != nil {
t.Fatal(err)
}
if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultUnitPrice != "39.90" ||
list.Rows[0].DefaultTotalPrice != "39.90" {
t.Fatalf("可采购阶段不对: %+v", list)
}
if list.OrderNoCount != 2 || list.MatchedOrderCount != 1 ||
len(list.UnmatchedOrderNos) != 1 || list.UnmatchedOrderNos[0] != "NOT-FOUND" {
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(&current)
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 := db.Exec(`UPDATE syb_orders SET quantity=2 WHERE syb_id='SYB-1'`); err != nil {
t.Fatal(err)
}
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", UnitPriceLimitCent: 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 != 8400 || task.MaxPriceCent == 999900 ||
task.Quantity != 2 || 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 != 2 || claimed.MaxPriceCent != 8400 {
t.Fatalf("现有 Client 领取契约读不到完整采购参数: task=%+v err=%v", claimed, err)
}
second, err := CreatePurchaseTasks(db, admin,
[]PurchaseTaskRequest{{SybID: "SYB-1", UnitPriceLimitCent: 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", UnitPriceLimitCent: 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", UnitPriceLimitCent: 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", UnitPriceLimitCent: 4200}}, "CLIENT-A"); err == nil {
t.Fatal("采购员不得使用其他采购员的客户端")
}
result, err := CreatePurchaseTasks(db, buyerA,
[]PurchaseTaskRequest{{SybID: "SYB-1", UnitPriceLimitCent: 0}, {SybID: "NOT-FOUND", UnitPriceLimitCent: 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", UnitPriceLimitCent: 100},
{SybID: "SYB-1", UnitPriceLimitCent: 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", UnitPriceLimitCent: 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", UnitPriceLimitCent: 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", UnitPriceLimitCent: 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", UnitPriceLimitCent: 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)
}
}
}
func TestCalculateOrderPriceLimitCent_按数量计算且拒绝溢出(t *testing.T) {
got, err := CalculateOrderPriceLimitCent(1180, 2)
if err != nil || got != 2360 {
t.Fatalf("1180 × 2 = %d, %v; want 2360", got, err)
}
for _, tc := range []struct {
unit int64
qty int
}{{0, 2}, {1180, 0}, {math.MaxInt64, 2}} {
if _, err := CalculateOrderPriceLimitCent(tc.unit, tc.qty); err == nil {
t.Fatalf("应拒绝 unit=%d qty=%d", tc.unit, tc.qty)
}
}
}