Files
cmautobuy/admin/service/purchase_workflow_test.go
T

385 lines
15 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"
"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(&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 := 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 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)
}
}
}