371 lines
12 KiB
Go
371 lines
12 KiB
Go
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 ©, 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
|
||
}
|
||
|
||
const LivePurchaseConfirmation = "创建未付款订单"
|
||
|
||
// PurchaseTaskOptions 是一次批量创建共用的执行门禁。
|
||
// ExecutionMode 留空表示 dry_run,确保旧调用和普通操作都保持安全默认值。
|
||
type PurchaseTaskOptions struct {
|
||
ClientID string
|
||
ExecutionMode model.TaskExecutionMode
|
||
LiveAcknowledged bool
|
||
LiveConfirmation string
|
||
}
|
||
|
||
// 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 := visibleClientUserID(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 {
|
||
if !options.LiveAcknowledged || strings.TrimSpace(options.LiveConfirmation) != LivePurchaseConfirmation {
|
||
return result, fmt.Errorf("真实下单必须勾选风险确认并输入“%s”", LivePurchaseConfirmation)
|
||
}
|
||
purchaseMode, err := repository.ClientPurchaseMode(tx, clientID)
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
if purchaseMode != string(model.TaskExecutionLive) {
|
||
return result, fmt.Errorf("所选客户端未声明 live 能力,不能执行真实采购任务")
|
||
}
|
||
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,
|
||
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.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 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
|
||
}
|