Files
goauto/server/app/goauto/purchase/batch.go
T

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"
}