Files
cmautobuy/admin/service/purchase_workflow.go
T

390 lines
13 KiB
Go
Raw Normal View History

package service
import (
"database/sql"
"fmt"
"math"
"strings"
"unicode/utf8"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
2026-08-10 12:24:23 +08:00
"cmautobuy/admin/spec"
)
// PddOptionChoice 是采集结果中的一个可购买规格组合。
type PddOptionChoice struct {
Key string
OptionsJSON string
Label string
PriceText string
PriceCent int64
HasPrice bool
Selected bool
Options map[string]string
Recommended bool
RecommendationReason string
MatchLevel string
}
func pddOptionChoices(raw string) ([]PddOptionChoice, []string, []string, error) {
collected, err := parseCollected(raw)
if err != nil {
return nil, 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, 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), Options: sku.Options,
}
if sku.PriceCent != nil && *sku.PriceCent > 0 {
choice.PriceCent, choice.HasPrice = *sku.PriceCent, true
}
choices = append(choices, choice)
}
return choices, keys, 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
}
2026-08-10 12:24:23 +08:00
// SaveSybMapping 保存顺运宝商品规格到当前 PDD 商品的可复用映射。
func SaveSybMapping(db *sql.DB, sybID, optionKey, operator string, expectedVersions ...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("顺运宝明细不存在")
}
expectedContextVersion := mappingContextVersion(*context)
if len(expectedVersions) > 0 {
expectedContextVersion = expectedVersions[0]
}
if expectedContextVersion == "" || mappingContextVersion(*context) != expectedContextVersion {
return fmt.Errorf("数据或匹配规则已变化,请刷新后重新核对")
}
2026-08-10 12:24:23 +08:00
key, err := spec.SpecKey(context.Order.ProductSpec)
if err != nil || context.Order.SpecKey == "" {
return fmt.Errorf("顺运宝未提供有效规格,不能保存映射")
}
if key != context.Order.SpecKey {
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 规格已不存在或不可购买,请刷新后重试")
}
choices, dimensionKeys, dimensionNames, err := pddOptionChoices(context.PddSkusJSON)
if err != nil {
return fmt.Errorf("读取 PDD 规格失败: %w", err)
}
match := rankSpecChoices(context.Order.ProductSpec, choices, dimensionKeys, dimensionNames)
if utf8.RuneCountInString(choice.Key) > 191 || utf8.RuneCountInString(match.SuggestedOptionKey) > 191 || utf8.RuneCountInString(SpecMatchRulesVersion) > 32 {
return fmt.Errorf("规格选项键或规则版本超过数据库列宽,未保存")
}
mappedAt := model.NowISO()
2026-08-10 12:24:23 +08:00
if err := repository.UpsertSpecMapping(tx, model.SpecMapping{
ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key,
SpecRaw: context.Order.ProductSpec, PddGoodsID: context.PddGoodsID,
PddOptionKey: choice.Key, PddOptions: choice.OptionsJSON,
MappedAt: mappedAt, MappedBy: strings.TrimSpace(operator), Source: "manual",
ContextVersion: expectedContextVersion,
}); err != nil {
return err
}
if err := repository.InsertSpecMappingDecision(tx, model.SpecMappingDecision{
ShopeeGoodsID: context.Order.ShopeeGoodsID, SpecKey: key, PddGoodsID: context.PddGoodsID,
RulesVersion: SpecMatchRulesVersion, SuggestedOptionKey: match.SuggestedOptionKey,
ChosenOptionKey: choice.Key, Accepted: match.SuggestedOptionKey != "" && match.SuggestedOptionKey == choice.Key,
DecidedBy: strings.TrimSpace(operator), DecidedAt: mappedAt,
}); 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 是一条采购任务的人工确认输入。价格单位固定为人民币分。
// UnitPriceLimitCent 是采购员确认的单价上限;订单总价上限必须在事务内按最新数量计算。
type PurchaseTaskRequest struct {
SybID string
UnitPriceLimitCent int64
MappingOptionKey string
ContextVersion string
}
// PurchaseTaskOptions 是一次批量创建共用的执行门禁。
// ExecutionMode 留空表示 dry_run,确保旧调用和普通操作都保持安全默认值。
type PurchaseTaskOptions struct {
ClientID string
ExecutionMode model.TaskExecutionMode
}
// PurchaseTaskResult 同时返回已创建数量和每条无法创建的原因。
type PurchaseTaskResult struct {
Created int
Failures []TaskCreateError
}
// CreatePurchaseTasks 保留旧调用入口;未传执行模式时必须安全地创建演练任务。
func CreatePurchaseTasks(db *sql.DB, actor *model.User, requests []PurchaseTaskRequest, clientID string) (PurchaseTaskResult, error) {
return CreatePurchaseTasksWithOptions(db, actor, requests, PurchaseTaskOptions{ClientID: clientID})
}
// CreatePurchaseTasksWithOptions 校验并创建采购任务。业务校验失败按明细返回,数据库错误整体回滚。
func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []PurchaseTaskRequest, options PurchaseTaskOptions) (PurchaseTaskResult, error) {
var result PurchaseTaskResult
if actor == nil {
return result, ErrUnauthenticated
}
if actor.Status != model.UserActive {
return result, fmt.Errorf("当前账号不是正常状态,不能创建采购任务")
}
visibleUserID, err := taskActorScope(actor)
if err != nil {
return result, err
}
clientID := strings.TrimSpace(options.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("所选客户端不存在或不在当前账号可见范围")
}
executionMode := options.ExecutionMode
if executionMode == "" {
executionMode = model.TaskExecutionDryRun
}
if executionMode != model.TaskExecutionDryRun && executionMode != model.TaskExecutionLive {
return result, fmt.Errorf("执行模式无效,请选择采购演练或真实下单")
}
confirmedBy, confirmedAt := "", ""
if executionMode == model.TaskExecutionLive {
confirmedBy, confirmedAt = actor.UserID, model.NowISO()
}
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
}
totalPriceLimitCent, err := CalculateOrderPriceLimitCent(request.UnitPriceLimitCent, context.Order.Quantity)
if err != nil {
result.Failures = append(result.Failures, TaskCreateError{SybID: request.SybID, Reason: err.Error()})
continue
}
taskID, err := repository.NextTaskID(tx, model.TaskPurchase)
if err != nil {
return PurchaseTaskResult{}, err
}
if err := repository.InsertPurchaseTask(tx, model.Task{
TaskID: taskID, AssignedClient: clientID,
CreatedByUserID: actor.UserID,
ExecutionMode: executionMode, LiveConfirmedBy: confirmedBy, LiveConfirmedAt: confirmedAt,
SybID: context.Order.SybID, OrderNo: context.Order.OrderNo,
2026-08-10 12:24:23 +08:00
GoodsID: context.Order.ShopeeGoodsID,
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
PddOptions: optionsJSON, Quantity: context.Order.Quantity,
MaxPriceCent: totalPriceLimitCent,
}); 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.HasSucceededPurchaseTask {
return "该顺运宝明细已有成功采购任务,禁止重复采购", context, "", nil
}
if context.HasManualReviewPurchaseTask {
return "该顺运宝明细有待人工核对的采购任务,请先核对订单,禁止重复采购", context, "", nil
}
2026-08-10 12:24:23 +08:00
if context.Order.SpecKey == "" {
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 request.MappingOptionKey != "" && request.MappingOptionKey != context.MappingOptionKey {
return "规格映射已变化,请刷新页面后重新确认", context, "", nil
}
if request.ContextVersion != "" && request.ContextVersion != mappingContextVersion(*context) {
return "商品或规格数据已变化,请刷新页面后重新确认", context, "", nil
}
if context.Order.Quantity <= 0 {
return "采购数量必须大于 0", context, "", nil
}
if request.UnitPriceLimitCent <= 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
}
// CalculateOrderPriceLimitCent 用整数分计算订单总价上限,避免浮点误差和乘法溢出。
func CalculateOrderPriceLimitCent(unitPriceLimitCent int64, quantity int) (int64, error) {
if unitPriceLimitCent <= 0 {
return 0, fmt.Errorf("人民币单价上限必须大于 0")
}
if quantity <= 0 {
return 0, fmt.Errorf("采购数量必须大于 0")
}
if unitPriceLimitCent > math.MaxInt64/int64(quantity) {
return 0, fmt.Errorf("订单总价上限过大")
}
return unitPriceLimitCent * int64(quantity), nil
}
// 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
}