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 }