516 lines
21 KiB
Go
516 lines
21 KiB
Go
package purchase
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"go-admin/app/goauto/aimatching"
|
|
"go-admin/app/goauto/models"
|
|
"go-admin/app/goauto/productspec"
|
|
"go-admin/app/goauto/purchasecontract"
|
|
"go-admin/app/goauto/purchaserule"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const maxBatchPurchaseItems = 100
|
|
|
|
type BatchPreviewRequest struct {
|
|
SYBProductIDs []uint64 `json:"sybProductIds"`
|
|
DeviceID *uint64 `json:"deviceId,omitempty"`
|
|
}
|
|
|
|
type BatchCreateRequest struct {
|
|
RequestID string `json:"requestId"`
|
|
SYBProductIDs []uint64 `json:"sybProductIds"`
|
|
DeviceID *uint64 `json:"deviceId,omitempty"`
|
|
}
|
|
|
|
type BatchPreviewItem struct {
|
|
SYBProductID uint64 `json:"sybProductId"`
|
|
OrderCode string `json:"orderCode,omitempty"`
|
|
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
|
|
ShopeeItemID string `json:"shopeeItemId,omitempty"`
|
|
ProductTitle string `json:"productTitle,omitempty"`
|
|
PDDProductID *uint64 `json:"pddProductId,omitempty"`
|
|
PDDGoodsID string `json:"pddGoodsId,omitempty"`
|
|
PDDTitle string `json:"pddTitle,omitempty"`
|
|
TargetColor string `json:"targetColor,omitempty"`
|
|
TargetSize string `json:"targetSize,omitempty"`
|
|
MappedColor string `json:"mappedColor,omitempty"`
|
|
MappedSize string `json:"mappedSize,omitempty"`
|
|
Quantity int64 `json:"quantity,omitempty"`
|
|
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent,omitempty"`
|
|
MinUnitPriceCent int64 `json:"minUnitPriceCent,omitempty"`
|
|
MaxUnitPriceCent int64 `json:"maxUnitPriceCent,omitempty"`
|
|
Currency string `json:"currency,omitempty"`
|
|
Eligible bool `json:"eligible"`
|
|
ReasonCode string `json:"reasonCode,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
NextAction string `json:"nextAction,omitempty"`
|
|
ActiveTaskID *uint64 `json:"activeTaskId,omitempty"`
|
|
ProcessStage string `json:"processStage,omitempty"`
|
|
ProcessStageLabel string `json:"processStageLabel,omitempty"`
|
|
ProcessStageReason string `json:"processStageReason,omitempty"`
|
|
ProcessNextAction string `json:"processNextAction,omitempty"`
|
|
CollectionEligible bool `json:"collectionEligible"`
|
|
CollectionDisabledReason string `json:"collectionDisabledReason,omitempty"`
|
|
AIMatchEligible bool `json:"aiMatchEligible"`
|
|
AIMatchDisabledReason string `json:"aiMatchDisabledReason,omitempty"`
|
|
}
|
|
|
|
type BatchPreviewResponse struct {
|
|
Items []BatchPreviewItem `json:"items"`
|
|
EligibleCount int `json:"eligibleCount"`
|
|
SkippedCount int `json:"skippedCount"`
|
|
}
|
|
|
|
type BatchCreateItem struct {
|
|
BatchPreviewItem
|
|
Created bool `json:"created"`
|
|
TaskID *uint64 `json:"taskId,omitempty"`
|
|
TaskNo string `json:"taskNo,omitempty"`
|
|
Replayed bool `json:"replayed,omitempty"`
|
|
}
|
|
|
|
type BatchCreateResponse struct {
|
|
Items []BatchCreateItem `json:"items"`
|
|
CreatedCount int `json:"createdCount"`
|
|
FailedCount int `json:"failedCount"`
|
|
}
|
|
|
|
func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (BatchPreviewResponse, error) {
|
|
ids, err := validateBatchIDs(req.SYBProductIDs)
|
|
if err != nil {
|
|
return BatchPreviewResponse{}, err
|
|
}
|
|
dataset, err := s.loadBatchPreviewDataset(ctx, ids)
|
|
if err != nil {
|
|
return BatchPreviewResponse{}, internal(err)
|
|
}
|
|
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
|
guard, ruleReason, err := readOnlyRuleResult(currentRule, ruleErr)
|
|
if err != nil {
|
|
return BatchPreviewResponse{}, err
|
|
}
|
|
var deviceErr error
|
|
if ruleReason == "" {
|
|
deviceErr = s.validateBatchDevice(ctx, req.DeviceID, currentRule)
|
|
}
|
|
items := make([]BatchPreviewItem, 0, len(ids))
|
|
eligible := 0
|
|
for _, id := range ids {
|
|
item := s.previewFromDataset(id, dataset, guard)
|
|
item.applyCollectionEligibility(id, dataset)
|
|
qualification := aiMatchQualificationForDataset(id, dataset)
|
|
item.AIMatchEligible = qualification.Eligible
|
|
item.AIMatchDisabledReason = qualification.DisabledReason
|
|
if ruleReason != "" && item.Eligible {
|
|
item.Eligible = false
|
|
item.ReasonCode, item.Reason, item.NextAction = purchaserule.CodeCurrentRuleMissing, ruleReason, "refresh"
|
|
}
|
|
item.applyProcessStage(processStageFromDataset(id, dataset, item))
|
|
if item.Eligible && deviceErr != nil {
|
|
item.Eligible = false
|
|
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(deviceErr)
|
|
}
|
|
if item.Eligible {
|
|
eligible++
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return BatchPreviewResponse{Items: items, EligibleCount: eligible, SkippedCount: len(items) - eligible}, nil
|
|
}
|
|
|
|
type batchPreviewDataset struct {
|
|
sybByID map[uint64]models.SYBProduct
|
|
shopeeByID map[uint64]models.ShopeeProduct
|
|
pddByID map[uint64]models.PDDProduct
|
|
latestTaskBySYB map[uint64]models.PurchaseTask
|
|
latestCollectionByPDD map[uint64]models.CollectionTask
|
|
activeCollectionByPDD map[uint64]uint64
|
|
collectionRuleAvailable bool
|
|
skuCombinationsByPDD map[uint64][]pddSKUCombination
|
|
}
|
|
|
|
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
|
|
// Collection eligibility adds bounded active-task and rule queries; pending
|
|
// products additionally load their latest task for the user-facing stage.
|
|
// Creation still revalidates each item.
|
|
func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (batchPreviewDataset, error) {
|
|
dataset := batchPreviewDataset{
|
|
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
|
|
shopeeByID: make(map[uint64]models.ShopeeProduct),
|
|
pddByID: make(map[uint64]models.PDDProduct),
|
|
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
|
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
|
|
activeCollectionByPDD: make(map[uint64]uint64),
|
|
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
|
|
}
|
|
var sybProducts []models.SYBProduct
|
|
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
|
|
return dataset, err
|
|
}
|
|
shopeeIDs := make([]uint64, 0, len(sybProducts))
|
|
for _, syb := range sybProducts {
|
|
dataset.sybByID[syb.ID] = syb
|
|
if syb.ShopeeProductID != nil {
|
|
shopeeIDs = append(shopeeIDs, *syb.ShopeeProductID)
|
|
}
|
|
}
|
|
if len(shopeeIDs) > 0 {
|
|
var shopeeProducts []models.ShopeeProduct
|
|
if err := s.DB.WithContext(ctx).Where("id IN ?", shopeeIDs).Find(&shopeeProducts).Error; err != nil {
|
|
return dataset, err
|
|
}
|
|
pddIDs := make([]uint64, 0, len(shopeeProducts))
|
|
for _, shopee := range shopeeProducts {
|
|
dataset.shopeeByID[shopee.ID] = shopee
|
|
if shopee.PDDProductID != nil {
|
|
pddIDs = append(pddIDs, *shopee.PDDProductID)
|
|
}
|
|
}
|
|
if len(pddIDs) > 0 {
|
|
var pddProducts []models.PDDProduct
|
|
if err := s.DB.WithContext(ctx).Where("id IN ?", pddIDs).Find(&pddProducts).Error; err != nil {
|
|
return dataset, err
|
|
}
|
|
for _, pdd := range pddProducts {
|
|
dataset.pddByID[pdd.ID] = pdd
|
|
}
|
|
if err := s.loadLatestSKUCombinations(ctx, pddIDs, &dataset); err != nil {
|
|
return dataset, err
|
|
}
|
|
var activeCollectionTasks []models.CollectionTask
|
|
if err := s.DB.WithContext(ctx).
|
|
Where("pdd_product_id IN ? AND status IN ?", pddIDs, []string{models.TaskStatusPending, models.TaskStatusRunning}).
|
|
Find(&activeCollectionTasks).Error; err != nil {
|
|
return dataset, err
|
|
}
|
|
for _, task := range activeCollectionTasks {
|
|
if task.PDDProductID != nil {
|
|
dataset.activeCollectionByPDD[*task.PDDProductID] = task.ID
|
|
}
|
|
}
|
|
var collectionRuleCount int64
|
|
if err := s.DB.WithContext(ctx).Model(&models.CollectionRule{}).Count(&collectionRuleCount).Error; err != nil {
|
|
return dataset, err
|
|
}
|
|
dataset.collectionRuleAvailable = collectionRuleCount > 0
|
|
pendingPDDIDs := make([]uint64, 0)
|
|
for _, pdd := range pddProducts {
|
|
if pdd.Status == "pending" {
|
|
pendingPDDIDs = append(pendingPDDIDs, pdd.ID)
|
|
}
|
|
}
|
|
if len(pendingPDDIDs) > 0 {
|
|
var collectionTasks []models.CollectionTask
|
|
if err := s.DB.WithContext(ctx).
|
|
Where("pdd_product_id IN ? AND id = (SELECT MAX(ct2.id) FROM collection_task ct2 WHERE ct2.pdd_product_id = collection_task.pdd_product_id AND ct2.deleted_at IS NULL)", pendingPDDIDs).
|
|
Find(&collectionTasks).Error; err != nil {
|
|
return dataset, err
|
|
}
|
|
for _, task := range collectionTasks {
|
|
if task.PDDProductID != nil {
|
|
dataset.latestCollectionByPDD[*task.PDDProductID] = task
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var tasks []models.PurchaseTask
|
|
if err := s.DB.WithContext(ctx).
|
|
Where("syb_product_id IN ? AND id = (SELECT MAX(pt2.id) FROM purchase_task pt2 WHERE pt2.syb_product_id = purchase_task.syb_product_id)", ids).
|
|
Find(&tasks).Error; err != nil {
|
|
return dataset, err
|
|
}
|
|
for _, task := range tasks {
|
|
if task.SYBProductID == nil {
|
|
continue
|
|
}
|
|
if _, exists := dataset.latestTaskBySYB[*task.SYBProductID]; !exists {
|
|
dataset.latestTaskBySYB[*task.SYBProductID] = task
|
|
}
|
|
}
|
|
return dataset, nil
|
|
}
|
|
|
|
func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (BatchCreateResponse, error) {
|
|
if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil {
|
|
return BatchCreateResponse{}, fail(CodeInvalidRequest, "requestId 无效")
|
|
}
|
|
ids, err := validateBatchIDs(req.SYBProductIDs)
|
|
if err != nil {
|
|
return BatchCreateResponse{}, err
|
|
}
|
|
_, rawRule, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
|
if err != nil {
|
|
return BatchCreateResponse{}, err
|
|
}
|
|
deviceErr := s.validateBatchDevice(ctx, req.DeviceID, currentRule)
|
|
result := BatchCreateResponse{Items: make([]BatchCreateItem, 0, len(ids))}
|
|
for _, id := range ids {
|
|
itemRequestID := batchItemRequestID(req.RequestID, id)
|
|
var replay models.PurchaseTask
|
|
if err := s.DB.WithContext(ctx).Where("create_request_id = ?", itemRequestID).First(&replay).Error; err == nil {
|
|
taskID := replay.ID
|
|
preview := replayBatchPreview(replay)
|
|
preview.Eligible, preview.ReasonCode, preview.Reason, preview.NextAction, preview.ActiveTaskID = true, "", "", "", nil
|
|
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview, Created: true, TaskID: &taskID, TaskNo: taskNumber(taskID), Replayed: true})
|
|
result.CreatedCount++
|
|
continue
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return BatchCreateResponse{}, internal(err)
|
|
}
|
|
|
|
preview := s.previewOneDeterministic(ctx, id, purchasecontract.EffectivePriceGuard(currentRule))
|
|
if preview.Eligible && deviceErr != nil {
|
|
preview.Eligible = false
|
|
preview.ReasonCode, preview.Reason, preview.NextAction = serviceErrorFields(deviceErr)
|
|
}
|
|
if !preview.Eligible {
|
|
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview})
|
|
result.FailedCount++
|
|
continue
|
|
}
|
|
sybID := id
|
|
task, replayed, createErr := s.Create(ctx, CreateRequest{
|
|
RequestID: itemRequestID, ExecutionMode: models.PurchaseExecutionModeLive,
|
|
SYBProductID: &sybID, DeviceID: req.DeviceID,
|
|
ReferenceUnitPriceCent: preview.ReferenceUnitPriceCent,
|
|
MinUnitPriceCent: preview.MinUnitPriceCent, MaxUnitPriceCent: preview.MaxUnitPriceCent,
|
|
Currency: preview.Currency, RuleSnapshot: rawRule,
|
|
})
|
|
if createErr != nil {
|
|
preview.Eligible = false
|
|
preview.ReasonCode, preview.Reason, preview.NextAction = serviceErrorFields(createErr)
|
|
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview})
|
|
result.FailedCount++
|
|
continue
|
|
}
|
|
taskID := task.ID
|
|
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview, Created: true, TaskID: &taskID, TaskNo: taskNumber(taskID), Replayed: replayed})
|
|
result.CreatedCount++
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateBatchIDs(raw []uint64) ([]uint64, error) {
|
|
if len(raw) == 0 || len(raw) > maxBatchPurchaseItems {
|
|
return nil, fail(CodeInvalidRequest, "sybProductIds 必须包含 1 至 100 条当前页商品")
|
|
}
|
|
seen := make(map[uint64]bool, len(raw))
|
|
ids := make([]uint64, 0, len(raw))
|
|
for _, id := range raw {
|
|
if id == 0 || seen[id] {
|
|
return nil, fail(CodeInvalidRequest, "sybProductIds 包含无效或重复的商品")
|
|
}
|
|
seen[id] = true
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rule purchasecontract.RuleSnapshot) error {
|
|
if deviceID == nil {
|
|
return nil
|
|
}
|
|
var record models.AgentDevice
|
|
if err := s.DB.WithContext(ctx).First(&record, *deviceID).Error; err != nil {
|
|
return notFound(err, "设备不存在")
|
|
}
|
|
if record.Status != models.DeviceStatusOnline || record.TokenRevokedAt != nil {
|
|
return fail(CodeInvalidRequest, "所选设备当前不可用,请选择在线设备或留空")
|
|
}
|
|
return ensureCapabilities(record, purchasecontract.RequiredCapabilities(rule))
|
|
}
|
|
|
|
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceLimits) BatchPreviewItem {
|
|
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
|
|
if err != nil {
|
|
item := BatchPreviewItem{SYBProductID: id}
|
|
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
|
|
return item
|
|
}
|
|
item := s.previewFromDataset(id, dataset, guard)
|
|
return item
|
|
}
|
|
|
|
func replayBatchPreview(task models.PurchaseTask) BatchPreviewItem {
|
|
return BatchPreviewItem{
|
|
SYBProductID: taskPointerValue(task.SYBProductID), ShopeeProductID: task.ShopeeProductID,
|
|
PDDProductID: &task.PDDProductID, ProductTitle: task.ShopeeTitleSnapshot,
|
|
PDDGoodsID: task.PDDGoodsIDSnapshot, PDDTitle: task.PDDTitleSnapshot,
|
|
TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot,
|
|
MappedColor: task.MappedColorSnapshot, MappedSize: task.MappedSizeSnapshot,
|
|
Quantity: task.Quantity, ReferenceUnitPriceCent: task.ReferenceUnitPriceCent,
|
|
MinUnitPriceCent: task.MinUnitPriceCent, MaxUnitPriceCent: task.MaxUnitPriceCent,
|
|
Currency: task.Currency, Eligible: true,
|
|
}
|
|
}
|
|
|
|
func taskPointerValue(value *uint64) uint64 {
|
|
if value == nil {
|
|
return 0
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, guard purchasecontract.PriceLimits) BatchPreviewItem {
|
|
item := BatchPreviewItem{SYBProductID: id}
|
|
syb, found := dataset.sybByID[id]
|
|
if !found {
|
|
item.ReasonCode, item.Reason, item.NextAction = "SYB_PRODUCT_NOT_FOUND", "SYB 商品不存在或已被删除", "refresh"
|
|
return item
|
|
}
|
|
item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID
|
|
item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity
|
|
if !sybSpecsTrusted(syb) {
|
|
item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse"
|
|
if syb.ParseStatus == models.SYBParseStatusUncertain {
|
|
item.Reason = "采购规格解析存疑,请先人工修正并确认"
|
|
} else {
|
|
item.Reason = "解析失败,请先重新解析或人工修正"
|
|
}
|
|
return item
|
|
}
|
|
if syb.ShopeeProductID == nil {
|
|
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_LINKED", "尚未关联蝦皮商品,请先重新导入或处理商品档案", "refresh"
|
|
return item
|
|
}
|
|
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
|
|
if !found {
|
|
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
|
|
return item
|
|
}
|
|
if !aiConfirmedSpecsCurrent(syb, shopee) {
|
|
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
|
|
return item
|
|
}
|
|
if shopee.PDDProductID == nil {
|
|
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink
|
|
return item
|
|
}
|
|
item.PDDProductID = shopee.PDDProductID
|
|
pdd, found := dataset.pddByID[*shopee.PDDProductID]
|
|
if !found {
|
|
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_FOUND", "关联的 PDD 商品不存在,请重新关联", processActionOpenPDDLink
|
|
return item
|
|
}
|
|
item.PDDGoodsID, item.PDDTitle = pdd.GoodsID, pdd.Title
|
|
if pdd.Status != "active" {
|
|
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_READY", "拼多多商品尚未采集完成或已停用", "open_pdd"
|
|
return item
|
|
}
|
|
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
|
item.MappedColor, item.MappedSize = mappedColor, mappedSize
|
|
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
|
// #190:失效的已保存映射不再拦截,降级为 unresolved,与创建路径保持一致。
|
|
if source != "unresolved" && !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
|
mappedColor, mappedSize, source = "", "", "unresolved"
|
|
item.MappedColor, item.MappedSize = "", ""
|
|
}
|
|
if source == "unresolved" {
|
|
request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
|
|
if matched, ok := aimatching.DeterministicMatch(request); ok {
|
|
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
|
}
|
|
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
|
|
}
|
|
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
|
|
if err != nil {
|
|
item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd"
|
|
return item
|
|
}
|
|
item.ReferenceUnitPriceCent, item.MinUnitPriceCent, item.MaxUnitPriceCent, item.Currency = reference, minPrice, maxPrice, "CNY"
|
|
if previous, found := dataset.latestTaskBySYB[syb.ID]; found {
|
|
item.ActiveTaskID = &previous.ID
|
|
switch previous.Status {
|
|
case models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusRehearsalCompleted:
|
|
case models.PurchaseTaskStatusOrderCreated:
|
|
if previous.PaymentReviewStatus == models.PurchasePaymentReviewPaid {
|
|
item.ReasonCode, item.Reason, item.NextAction = "PAID_ORDER_EXISTS", "该商品已有已支付订单,不能再次创建", "open_task"
|
|
return item
|
|
}
|
|
if previous.RePurchaseAuthorizedAt == nil || previous.RePurchaseConsumedAt != nil {
|
|
item.ReasonCode, item.Reason, item.NextAction = CodeRePurchaseRequired, "该商品已经采购成功,请先在采购任务中允许重新采购", "open_task"
|
|
return item
|
|
}
|
|
default:
|
|
item.ReasonCode, item.Reason, item.NextAction = CodeStateConflict, "该商品已有未结束的采购任务", "open_task"
|
|
return item
|
|
}
|
|
}
|
|
item.ActiveTaskID = nil
|
|
item.Eligible = true
|
|
return item
|
|
}
|
|
|
|
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceLimits) (reference, minPrice, maxPrice int64, err error) {
|
|
var dimensions []productspec.Dimension
|
|
if json.Unmarshal([]byte(raw), &dimensions) != nil {
|
|
return 0, 0, 0, errors.New("拼多多商品规格数据无效,请先重新采集")
|
|
}
|
|
prices := make([]int64, 0)
|
|
for _, dimension := range dimensions {
|
|
if dimension.Role != "color" {
|
|
continue
|
|
}
|
|
for _, value := range dimension.Values {
|
|
if !value.Selectable || value.PriceCent == nil || *value.PriceCent < 0 {
|
|
continue
|
|
}
|
|
if mappedColor == "" || value.Name == mappedColor {
|
|
prices = append(prices, *value.PriceCent)
|
|
}
|
|
}
|
|
}
|
|
if len(prices) == 0 {
|
|
if mappedColor != "" {
|
|
return 0, 0, 0, fmt.Errorf("拼多多颜色“%s”没有可用价格,请先检查商品规格", mappedColor)
|
|
}
|
|
return 0, 0, 0, errors.New("拼多多商品没有可用的颜色价格,请先采集或录入")
|
|
}
|
|
sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
|
|
low, high := prices[0], prices[len(prices)-1]
|
|
if !guard.Enabled {
|
|
return high, 0, guard.AbsoluteMaxUnitPriceCent, nil
|
|
}
|
|
minRatio, maxRatio := int64(guard.MinRatio), int64(guard.MaxRatio)
|
|
return high, low * minRatio / purchasecontract.RatioScale, (high*maxRatio + purchasecontract.RatioScale - 1) / purchasecontract.RatioScale, nil
|
|
}
|
|
|
|
func batchItemRequestID(batchRequestID string, sybProductID uint64) string {
|
|
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(fmt.Sprintf("%s:%d", batchRequestID, sybProductID))).String()
|
|
}
|
|
|
|
func taskNumber(id uint64) string { return fmt.Sprintf("CG-%d", id) }
|
|
|
|
func serviceErrorFields(err error) (string, string, string) {
|
|
var serviceErr *ServiceError
|
|
if errors.As(err, &serviceErr) {
|
|
next := "retry"
|
|
switch serviceErr.Code {
|
|
case CodeMappingRequired:
|
|
next = "open_mapping"
|
|
case CodeStateConflict, CodeRePurchaseRequired:
|
|
next = "open_task"
|
|
case CodeCapabilityMismatch:
|
|
next = "select_device"
|
|
case CodeInvalidRequest:
|
|
if strings.Contains(serviceErr.Message, "设备") {
|
|
next = "select_device"
|
|
} else {
|
|
next = "refresh"
|
|
}
|
|
}
|
|
return serviceErr.Code, serviceErr.Message, next
|
|
}
|
|
return CodeInternal, "服务端处理失败", "retry"
|
|
}
|