Files
cmautobuy/admin/service/purchase_workflow.go
T

294 lines
8.5 KiB
Go
Raw Normal View History

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 &copy, 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, &cent); 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
}