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("规格选项键或规则版本超过数据库列宽,未保存") } mappedAt := model.NowISO() 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, 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 } 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, ¢); 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 }