Files
cmautobuy/admin/service/purchase_workflow.go
T

372 lines
12 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"
"fmt"
"math"
"strings"
"unicode/utf8"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"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
}
// 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("数据或匹配规则已变化,请刷新后重新核对")
}
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("规格选项键或规则版本超过数据库列宽,未保存")
}
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: model.NowISO(), MappedBy: strings.TrimSpace(operator),
}); 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: model.NowISO(),
}); 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
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
}
if err := repository.InsertPurchaseTask(tx, model.Task{
TaskID: newPurchaseTaskID(), AssignedClient: clientID,
CreatedByUserID: actor.UserID,
ExecutionMode: executionMode, LiveConfirmedBy: confirmedBy, LiveConfirmedAt: confirmedAt,
SybID: context.Order.SybID, OrderNo: context.Order.OrderNo,
GoodsID: context.Order.ShopeeGoodsID,
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.HasSucceededPurchaseTask {
return "该顺运宝明细已有成功采购任务,禁止重复采购", context, "", nil
}
if context.HasManualReviewPurchaseTask {
return "该顺运宝明细有待人工核对的采购任务,请先核对订单,禁止重复采购", context, "", nil
}
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.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
}