feat: 完成规格映射与采购任务创建 (#69)
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
// PddOptionChoice 是采集结果中的一个可购买规格组合。
|
||||
type PddOptionChoice struct {
|
||||
Key string
|
||||
OptionsJSON string
|
||||
Label string
|
||||
PriceText string
|
||||
PriceCent int64
|
||||
HasPrice bool
|
||||
Selected bool
|
||||
}
|
||||
|
||||
func pddOptionChoices(raw string) ([]PddOptionChoice, []string, error) {
|
||||
collected, err := parseCollected(raw)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keys, names := dimensionOrder(collected)
|
||||
choices := make([]PddOptionChoice, 0, len(collected.SKUs))
|
||||
seen := map[string]bool{}
|
||||
for _, sku := range collected.SKUs {
|
||||
if !sku.Available || len(sku.Options) == 0 {
|
||||
continue
|
||||
}
|
||||
key, err := OptionKey(sku.Options)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
parts := make([]string, 0, len(keys))
|
||||
for i, dimensionKey := range keys {
|
||||
name := dimensionKey
|
||||
if i < len(names) && names[i] != "" {
|
||||
name = names[i]
|
||||
}
|
||||
parts = append(parts, name+":"+sku.Options[dimensionKey])
|
||||
}
|
||||
choice := PddOptionChoice{
|
||||
Key: key, OptionsJSON: key, Label: strings.Join(parts, " / "),
|
||||
PriceText: formatPriceCent(sku.PriceCent),
|
||||
}
|
||||
if sku.PriceCent != nil && *sku.PriceCent > 0 {
|
||||
choice.PriceCent, choice.HasPrice = *sku.PriceCent, true
|
||||
}
|
||||
choices = append(choices, choice)
|
||||
}
|
||||
return choices, names, nil
|
||||
}
|
||||
|
||||
func findPddChoice(raw, key string) (*PddOptionChoice, error) {
|
||||
choices, _, err := pddOptionChoices(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, choice := range choices {
|
||||
if choice.Key == key {
|
||||
copy := choice
|
||||
return ©, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SaveSybMapping 保存顺运宝明细对应蝦皮 SKU 到当前 PDD 商品的可复用映射。
|
||||
func SaveSybMapping(db *sql.DB, sybID, optionKey, operator 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("顺运宝明细不存在")
|
||||
}
|
||||
if context.Order.ShopeeSKUID == "" {
|
||||
return fmt.Errorf("请先确认蝦皮规格")
|
||||
}
|
||||
if context.PddGoodsID == "" || context.PddCollectStatus != string(model.CollectCollected) ||
|
||||
strings.TrimSpace(context.PddSkusJSON) == "" {
|
||||
return fmt.Errorf("当前 PDD 商品尚未完成采集")
|
||||
}
|
||||
choice, err := findPddChoice(context.PddSkusJSON, strings.TrimSpace(optionKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 PDD 规格失败: %w", err)
|
||||
}
|
||||
if choice == nil {
|
||||
return fmt.Errorf("所选 PDD 规格已不存在或不可购买,请刷新后重试")
|
||||
}
|
||||
if err := repository.UpsertSKUMapping(tx, model.SKUMapping{
|
||||
ShopeeSKUID: context.Order.ShopeeSKUID, PddGoodsID: context.PddGoodsID,
|
||||
PddOptionKey: choice.Key, PddOptions: choice.OptionsJSON,
|
||||
GoodsID: context.Order.ShopeeGoodsID, MappedAt: model.NowISO(),
|
||||
MappedBy: strings.TrimSpace(operator),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func mappingIsValid(context repository.SybOrderContext) bool {
|
||||
if context.MappingOptionKey == "" || context.PddSkusJSON == "" {
|
||||
return false
|
||||
}
|
||||
choice, err := findPddChoice(context.PddSkusJSON, context.MappingOptionKey)
|
||||
return err == nil && choice != nil
|
||||
}
|
||||
|
||||
// PurchaseTaskRequest 是一条采购任务的人工确认输入。价格单位固定为人民币分。
|
||||
type PurchaseTaskRequest struct {
|
||||
SybID string
|
||||
MaxPriceCent int64
|
||||
}
|
||||
|
||||
// PurchaseTaskResult 同时返回已创建数量和每条无法创建的原因。
|
||||
type PurchaseTaskResult struct {
|
||||
Created int
|
||||
Failures []TaskCreateError
|
||||
}
|
||||
|
||||
// CreatePurchaseTasks 校验并创建采购任务。业务校验失败按明细返回,数据库错误整体回滚。
|
||||
func CreatePurchaseTasks(db *sql.DB, actor *model.User, requests []PurchaseTaskRequest, clientID string) (PurchaseTaskResult, error) {
|
||||
var result PurchaseTaskResult
|
||||
visibleUserID, err := visibleClientUserID(actor)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
if clientID == "" {
|
||||
return result, fmt.Errorf("请选择执行采购任务的客户端")
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("开始创建采购任务事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
visible, err := repository.ClientVisibleToUser(tx, clientID, visibleUserID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if !visible {
|
||||
return result, fmt.Errorf("所选客户端不存在或不在当前账号可见范围")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, request := range requests {
|
||||
request.SybID = strings.TrimSpace(request.SybID)
|
||||
if request.SybID == "" || seen[request.SybID] {
|
||||
if request.SybID != "" {
|
||||
result.Failures = append(result.Failures, TaskCreateError{SybID: request.SybID, Reason: "重复选择"})
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen[request.SybID] = true
|
||||
reason, context, optionsJSON, err := validatePurchaseRequest(tx, request)
|
||||
if err != nil {
|
||||
return PurchaseTaskResult{}, err
|
||||
}
|
||||
if reason != "" {
|
||||
result.Failures = append(result.Failures, TaskCreateError{SybID: request.SybID, Reason: reason})
|
||||
continue
|
||||
}
|
||||
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,
|
||||
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
|
||||
PddOptions: optionsJSON, Quantity: context.Order.Quantity,
|
||||
MaxPriceCent: request.MaxPriceCent,
|
||||
}); err != nil {
|
||||
return PurchaseTaskResult{}, err
|
||||
}
|
||||
result.Created++
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return PurchaseTaskResult{}, fmt.Errorf("提交采购任务失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) (string, *repository.SybOrderContext, string, error) {
|
||||
context, err := repository.GetSybOrderContext(q, request.SybID)
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
if context == nil {
|
||||
return "顺运宝明细不存在", nil, "", nil
|
||||
}
|
||||
if context.Order.ShopeeSKUID == "" {
|
||||
return "蝦皮规格未确认", context, "", nil
|
||||
}
|
||||
if context.PddGoodsID == "" || context.PddGoodsURL == "" {
|
||||
return "未关联 PDD 商品", context, "", nil
|
||||
}
|
||||
if context.PddCollectStatus != string(model.CollectCollected) || context.PddSkusJSON == "" {
|
||||
return "PDD 商品尚未采集完成", context, "", nil
|
||||
}
|
||||
if context.MappingOptionKey == "" {
|
||||
return "PDD 规格尚未匹配", context, "", nil
|
||||
}
|
||||
choice, err := findPddChoice(context.PddSkusJSON, context.MappingOptionKey)
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
if choice == nil {
|
||||
return "已保存的 PDD 规格已失效,请重新匹配", context, "", nil
|
||||
}
|
||||
if context.Order.Quantity <= 0 {
|
||||
return "采购数量必须大于 0", context, "", nil
|
||||
}
|
||||
if request.MaxPriceCent <= 0 {
|
||||
return "人民币价格上限必须大于 0", context, "", nil
|
||||
}
|
||||
active, err := repository.HasActivePurchaseTask(q, request.SybID)
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
if active {
|
||||
return "已有未结束的采购任务", context, "", nil
|
||||
}
|
||||
return "", context, choice.OptionsJSON, nil
|
||||
}
|
||||
|
||||
func newPurchaseTaskID() string {
|
||||
id := newID()
|
||||
if len(id) > 16 {
|
||||
id = id[:16]
|
||||
}
|
||||
return "PUR-" + id
|
||||
}
|
||||
|
||||
// parsePriceYuanToCent 把网页输入的人民币元精确转成分,不使用浮点数。
|
||||
func ParsePriceYuanToCent(raw string) (int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("价格上限不能为空")
|
||||
}
|
||||
var whole, fraction string
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) > 2 {
|
||||
return 0, fmt.Errorf("价格格式不正确")
|
||||
}
|
||||
whole = parts[0]
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
}
|
||||
if whole == "" || len(fraction) > 2 {
|
||||
return 0, fmt.Errorf("价格最多保留两位小数")
|
||||
}
|
||||
for _, value := range []string{whole, fraction} {
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, fmt.Errorf("价格只能填写数字")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(fraction) == 0 {
|
||||
fraction = "00"
|
||||
} else if len(fraction) == 1 {
|
||||
fraction += "0"
|
||||
}
|
||||
var yuan, cent int64
|
||||
if _, err := fmt.Sscan(whole, &yuan); err != nil {
|
||||
return 0, fmt.Errorf("价格格式不正确")
|
||||
}
|
||||
if _, err := fmt.Sscan(fraction, ¢); err != nil {
|
||||
return 0, fmt.Errorf("价格格式不正确")
|
||||
}
|
||||
if yuan > (math.MaxInt64-cent)/100 {
|
||||
return 0, fmt.Errorf("价格上限过大")
|
||||
}
|
||||
total := yuan*100 + cent
|
||||
if total <= 0 {
|
||||
return 0, fmt.Errorf("价格上限必须大于 0")
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
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, _ = GetSybProcessingDetail(db, "SYB-1")
|
||||
if !detail.MappingValid {
|
||||
t.Fatal("保存后相同蝦皮 SKU + 当前 PDD 商品应自动复用")
|
||||
}
|
||||
list, _ := ListSybOrdersView(db, "", SybStagePurchaseReady, 1)
|
||||
if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultMaxPrice != "39.90" {
|
||||
t.Fatalf("可采购阶段不对: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
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 商品后不得复用旧映射")
|
||||
}
|
||||
})
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,17 +40,6 @@ func CreateCollectTasks(db *sql.DB, goodsIDs []string, clientID string) (created
|
||||
return 0, 0, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---------- 规格匹配 ----------
|
||||
|
||||
// SaveMapping 保存「蝦皮规格 = PDD 规格」的对应关系。
|
||||
//
|
||||
// 存的是**可复用的映射**(sku_mappings 表),不是某一张订单的临时数据。
|
||||
// 下次遇到同一个蝦皮 SKU 自动带出,操作员只需确认。
|
||||
func SaveMapping(db *sql.DB, shopeeSKUID, goodsID, pddOptionsJSON, operator string) error {
|
||||
// TODO(骨架): upsert sku_mappings
|
||||
return ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---------- 采购任务 ----------
|
||||
|
||||
// TaskCreateError 说明某一条为什么建不了任务。
|
||||
@@ -60,23 +49,6 @@ type TaskCreateError struct {
|
||||
Reason string // 例如「该商品未填写 PDD 链接」
|
||||
}
|
||||
|
||||
// CreatePurchaseTasks 由货运单创建采购任务。
|
||||
//
|
||||
// 六条校验缺一不可(docs/admin/01-requirements.md §5):
|
||||
// 1. 已填 PDD 链接
|
||||
// 2. 已采集成功(pdd_data 非空)
|
||||
// 3. 已有 SKU 映射
|
||||
// 4. 数量 > 0
|
||||
// 5. **价格上限已填且 > 0**——默认从 pdd_data 里该 SKU 的价格带出,
|
||||
// 操作员可改但不允许为空。没有它 Client 会拒绝执行。
|
||||
// 6. 已选择分配的客户端
|
||||
//
|
||||
// 另外 pdd_goods_url 必须写进任务,Client 那边是 NOT NULL。
|
||||
func CreatePurchaseTasks(db *sql.DB, sybIDs []string, clientID string) (created int, failures []TaskCreateError, err error) {
|
||||
// TODO(骨架)
|
||||
return 0, nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
// ---------- 客户端 ----------
|
||||
|
||||
// RegisterClient 登记或更新一台客户端。
|
||||
|
||||
+153
-66
@@ -916,31 +916,36 @@ func EnsureSybSession(db *sql.DB, client *syb.Client, username string, now time.
|
||||
// SybOrderView 是列表页一行要显示的全部内容,已经格式化成字符串,
|
||||
// 模板里不做判断和格式化,和其余四个模块的做法一致。
|
||||
type SybOrderView struct {
|
||||
SybID string
|
||||
OrderNo string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
ShopeeSKUID string
|
||||
Quantity int
|
||||
PriceText string // "NT$239.00",和人民币价格一眼分得清
|
||||
ImageURL string
|
||||
Stage string
|
||||
StageText string
|
||||
StageHelp string
|
||||
ActionText string
|
||||
NeedsAttention bool
|
||||
UpdatedAt string
|
||||
SybID string
|
||||
OrderNo string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
ShopeeSKUID string
|
||||
Quantity int
|
||||
PriceText string // "NT$239.00",和人民币价格一眼分得清
|
||||
ImageURL string
|
||||
Stage string
|
||||
StageText string
|
||||
StageHelp string
|
||||
ActionText string
|
||||
NeedsAttention bool
|
||||
CanPurchase bool
|
||||
DefaultMaxPrice string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
const (
|
||||
SybStageMissingShopee = "missing_shopee"
|
||||
SybStageSKUPending = "sku_pending"
|
||||
SybStagePddMissing = "pdd_missing"
|
||||
SybStagePddPending = "pdd_pending"
|
||||
SybStagePddCollecting = "pdd_collecting"
|
||||
SybStagePddFailed = "pdd_failed"
|
||||
SybStageMappingPending = "pdd_collected"
|
||||
SybStageMissingShopee = "missing_shopee"
|
||||
SybStageSKUPending = "sku_pending"
|
||||
SybStagePddMissing = "pdd_missing"
|
||||
SybStagePddPending = "pdd_pending"
|
||||
SybStagePddCollecting = "pdd_collecting"
|
||||
SybStagePddFailed = "pdd_failed"
|
||||
SybStageMappingPending = "mapping_pending"
|
||||
SybStagePurchaseReady = "purchase_ready"
|
||||
SybStageTaskCreated = "task_created"
|
||||
SybStagePurchaseBlocked = "purchase_blocked"
|
||||
)
|
||||
|
||||
// SybStageOption 是顺运宝处理阶段筛选项。
|
||||
@@ -959,6 +964,9 @@ func SybStageOptions() []SybStageOption {
|
||||
{Value: SybStagePddCollecting, Text: "PDD 采集中"},
|
||||
{Value: SybStagePddFailed, Text: "PDD 采集失败"},
|
||||
{Value: SybStageMappingPending, Text: "规格待匹配"},
|
||||
{Value: SybStagePurchaseReady, Text: "可创建采购任务"},
|
||||
{Value: SybStageTaskCreated, Text: "已创建采购任务"},
|
||||
{Value: SybStagePurchaseBlocked, Text: "采购数据异常"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -998,8 +1006,14 @@ func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string
|
||||
help += " 原因:" + c.PddCollectMsg
|
||||
}
|
||||
return SybStagePddFailed, "PDD 采集失败", help, "重新采集"
|
||||
default:
|
||||
case c.HasActiveTask:
|
||||
return SybStageTaskCreated, "已创建采购任务", "已有未结束的采购任务,请到采集采购页面查看。", "查看任务"
|
||||
case !mappingIsValid(c):
|
||||
return SybStageMappingPending, "规格待匹配", "PDD 数据已采集,下一步匹配采购规格。", "匹配规格"
|
||||
case c.Order.Quantity <= 0:
|
||||
return SybStagePurchaseBlocked, "采购数据异常", "顺运宝采购数量不是正数,不能创建采购任务。", "核对数据"
|
||||
default:
|
||||
return SybStagePurchaseReady, "可创建采购任务", "规格映射有效,可以勾选并创建采购任务。", "核对规格"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1021,6 +1035,10 @@ type SybListResult struct {
|
||||
// 顺运宝规格对应的蝦皮 SKU,不代表已经完成 PDD 规格映射。
|
||||
func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
|
||||
stage = ParseSybStage(stage)
|
||||
if stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
|
||||
stage == SybStageTaskCreated || stage == SybStagePurchaseBlocked {
|
||||
return listAdvancedSybStage(db, keyword, stage, page)
|
||||
}
|
||||
filter := repository.SybOrderFilter{Keyword: keyword, Stage: stage}
|
||||
total, err := repository.CountSybOrders(db, filter)
|
||||
if err != nil {
|
||||
@@ -1050,36 +1068,87 @@ func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListRes
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
for _, context := range rows {
|
||||
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,
|
||||
UpdatedAt: formatLocalTime(o.UpdatedAt),
|
||||
}
|
||||
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
|
||||
v.NeedsAttention = v.Stage != SybStagePddCollecting
|
||||
if o.PriceTwdCent > 0 {
|
||||
v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100)
|
||||
} else {
|
||||
v.PriceText = placeholder
|
||||
}
|
||||
if o.Title == "" {
|
||||
v.Title = placeholder
|
||||
}
|
||||
if o.ProductSpec == "" {
|
||||
v.ProductSpec = placeholder
|
||||
}
|
||||
result.Rows = append(result.Rows, v)
|
||||
result.Rows = append(result.Rows, sybOrderViewFor(context))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// listAdvancedSybStage 在 Service 解析最新 skus_json 后筛选映射相关阶段。
|
||||
// 这三个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
|
||||
// OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表
|
||||
// 和其余阶段仍在数据库分页。
|
||||
func listAdvancedSybStage(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) {
|
||||
contexts, err := repository.ListSybOrderContexts(db,
|
||||
repository.SybOrderFilter{Keyword: keyword}, -1, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := make([]repository.SybOrderContext, 0)
|
||||
for _, context := range contexts {
|
||||
value, _, _, _ := sybStageFor(context)
|
||||
if value == stage {
|
||||
filtered = append(filtered, context)
|
||||
}
|
||||
}
|
||||
total := len(filtered)
|
||||
totalPages := TotalPages(total)
|
||||
page = ClampPage(page, totalPages)
|
||||
start := (page - 1) * PageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + PageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
result := &SybListResult{Total: total, Stage: stage, Page: page, TotalPages: totalPages,
|
||||
IsFiltered: true, Rows: make([]SybOrderView, 0, end-start)}
|
||||
hasAny, err := repository.CountSybOrdersTotal(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.HasAny = hasAny > 0
|
||||
for _, context := range filtered[start:end] {
|
||||
result.Rows = append(result.Rows, sybOrderViewFor(context))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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,
|
||||
UpdatedAt: formatLocalTime(o.UpdatedAt)}
|
||||
v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context)
|
||||
v.CanPurchase = v.Stage == SybStagePurchaseReady
|
||||
v.DefaultMaxPrice = defaultMaxPrice(context)
|
||||
v.NeedsAttention = v.Stage != SybStagePddCollecting && v.Stage != SybStageTaskCreated
|
||||
if o.PriceTwdCent > 0 {
|
||||
v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100)
|
||||
} else {
|
||||
v.PriceText = placeholder
|
||||
}
|
||||
if v.Title == "" {
|
||||
v.Title = placeholder
|
||||
}
|
||||
if v.ProductSpec == "" {
|
||||
v.ProductSpec = placeholder
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func defaultMaxPrice(context repository.SybOrderContext) string {
|
||||
if !mappingIsValid(context) {
|
||||
return ""
|
||||
}
|
||||
choice, err := findPddChoice(context.PddSkusJSON, context.MappingOptionKey)
|
||||
if err != nil || choice == nil || !choice.HasPrice {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
|
||||
}
|
||||
|
||||
// AutoResolveSybShopeeSKU 只接受确定性唯一候选:先比规格原文,再比解析后的
|
||||
// 颜色和尺码。零候选或多候选都不写库,交给采购员确认。
|
||||
func AutoResolveSybShopeeSKU(q repository.Execer, order model.SybOrder) (string, error) {
|
||||
@@ -1165,23 +1234,27 @@ type SybSKUCandidateView struct {
|
||||
|
||||
// SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。
|
||||
type SybProcessingDetail struct {
|
||||
SybID string
|
||||
OrderNo string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
ShopeeSKUID string
|
||||
Quantity int
|
||||
Stage string
|
||||
StageText string
|
||||
StageHelp string
|
||||
Candidates []SybSKUCandidateView
|
||||
ShopeeExists bool
|
||||
PddGoodsID string
|
||||
PddURL string
|
||||
CollectStatus string
|
||||
CollectMsg string
|
||||
CanCollect bool
|
||||
SybID string
|
||||
OrderNo string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
ShopeeSKUID string
|
||||
Quantity int
|
||||
Stage string
|
||||
StageText string
|
||||
StageHelp string
|
||||
Candidates []SybSKUCandidateView
|
||||
ShopeeExists bool
|
||||
PddGoodsID string
|
||||
PddURL string
|
||||
CollectStatus string
|
||||
CollectMsg string
|
||||
CanCollect bool
|
||||
PddDimensionNames []string
|
||||
PddChoices []PddOptionChoice
|
||||
MappingValid bool
|
||||
HasActiveTask bool
|
||||
}
|
||||
|
||||
func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) {
|
||||
@@ -1200,6 +1273,20 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
|
||||
d.CanCollect = context.PddGoodsID != "" &&
|
||||
(context.PddCollectStatus == string(model.CollectPending) ||
|
||||
context.PddCollectStatus == string(model.CollectFailed))
|
||||
d.HasActiveTask = context.HasActiveTask
|
||||
if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" {
|
||||
choices, names, parseErr := pddOptionChoices(context.PddSkusJSON)
|
||||
if parseErr == nil {
|
||||
d.PddDimensionNames = names
|
||||
d.PddChoices = choices
|
||||
for i := range d.PddChoices {
|
||||
d.PddChoices[i].Selected = d.PddChoices[i].Key == context.MappingOptionKey
|
||||
if d.PddChoices[i].Selected {
|
||||
d.MappingValid = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context)
|
||||
if context.ShopeeExists {
|
||||
skus, err := repository.ListShopeeSKUsByGoodsID(db, o.ShopeeGoodsID)
|
||||
|
||||
Reference in New Issue
Block a user