402 lines
17 KiB
Go
402 lines
17 KiB
Go
package purchase
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"go-admin/app/goauto/models"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type AdminListRequest struct {
|
|
Page int
|
|
PageSize int
|
|
TaskID uint64
|
|
Status string
|
|
ExecutionMode string
|
|
TaskType string
|
|
SYBProductID uint64
|
|
ShopeeOrderNo string
|
|
PDDOrderNo string
|
|
}
|
|
|
|
type AdminTaskItem struct {
|
|
OrderWriteback OrderWritebackView `json:"orderWriteback"`
|
|
ID uint64 `json:"id"`
|
|
TaskType string `json:"taskType"`
|
|
ExecutionMode string `json:"executionMode"`
|
|
Status string `json:"status"`
|
|
SYBProductID *uint64 `json:"sybProductId,omitempty"`
|
|
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
|
|
PDDProductID uint64 `json:"pddProductId"`
|
|
DeviceID *uint64 `json:"deviceId,omitempty"`
|
|
DeviceName string `json:"deviceName,omitempty"`
|
|
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
|
|
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
|
|
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
|
|
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
|
|
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
|
|
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
|
|
TargetColorSnapshot string `json:"targetColorSnapshot"`
|
|
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
|
|
MappedColorSnapshot string `json:"mappedColorSnapshot"`
|
|
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
|
|
SpecSource string `json:"specSource"`
|
|
Quantity int64 `json:"quantity"`
|
|
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
|
|
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
|
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
|
Currency string `json:"currency"`
|
|
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
|
|
AddressSuffix string `json:"addressSuffix"`
|
|
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
|
|
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
|
|
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
|
|
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
|
|
PaymentReviewStatus string `json:"paymentReviewStatus"`
|
|
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
|
|
TrackingNo *string `json:"trackingNo,omitempty"`
|
|
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
|
|
LogisticsStatus string `json:"logisticsStatus"`
|
|
WritebackStatus string `json:"writebackStatus"`
|
|
WritebackAt *time.Time `json:"writebackAt,omitempty"`
|
|
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
|
|
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
|
|
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
|
|
CancelReason *string `json:"cancelReason,omitempty"`
|
|
ErrorCode *string `json:"errorCode,omitempty"`
|
|
ErrorMessage *string `json:"errorMessage,omitempty"`
|
|
Retryable bool `json:"retryable"`
|
|
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
|
|
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
|
|
StatusVersion uint64 `json:"statusVersion"`
|
|
StatusChangedAt time.Time `json:"statusChangedAt"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
Matching MatchingView `json:"matching"`
|
|
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
|
|
}
|
|
|
|
type AdminAttemptItem struct {
|
|
AttemptID string `json:"attemptId"`
|
|
AttemptNumber int `json:"attemptNumber"`
|
|
Phase string `json:"phase"`
|
|
Status string `json:"status"`
|
|
DeviceID *uint64 `json:"deviceId,omitempty"`
|
|
RuleSnapshotHash string `json:"ruleSnapshotHash"`
|
|
ResultType *string `json:"resultType,omitempty"`
|
|
ErrorCode *string `json:"errorCode,omitempty"`
|
|
ErrorMessage *string `json:"errorMessage,omitempty"`
|
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
|
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type AdminListResponse struct {
|
|
Items []AdminTaskItem `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"pageSize"`
|
|
}
|
|
|
|
type AdminDetailResponse struct {
|
|
Task AdminTaskItem `json:"task"`
|
|
Attempts []AdminAttemptItem `json:"attempts"`
|
|
Matching MatchingView `json:"matching"`
|
|
}
|
|
|
|
// Query-only value: never a PurchaseTask status or a logistics writeback state.
|
|
const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded"
|
|
|
|
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
|
|
if req.Page < 1 {
|
|
req.Page = 1
|
|
}
|
|
if req.PageSize < 1 {
|
|
req.PageSize = 20
|
|
}
|
|
if req.PageSize > 100 {
|
|
req.PageSize = 100
|
|
}
|
|
if req.Status != "" && req.Status != adminStatusSYBWritebackSucceeded && !validPurchaseStatus(req.Status) {
|
|
return AdminListResponse{}, fail(CodeInvalidRequest, "status 无效")
|
|
}
|
|
if req.ExecutionMode != "" && req.ExecutionMode != models.PurchaseExecutionModeRehearsal && req.ExecutionMode != models.PurchaseExecutionModeLive {
|
|
return AdminListResponse{}, fail(CodeInvalidRequest, "executionMode 无效")
|
|
}
|
|
if req.TaskType != "" && req.TaskType != models.PurchaseTaskTypeSYBOrder && req.TaskType != models.PurchaseTaskTypeStock {
|
|
return AdminListResponse{}, fail(CodeInvalidRequest, "taskType 无效")
|
|
}
|
|
query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{})
|
|
if req.TaskID > 0 {
|
|
query = query.Where("id = ?", req.TaskID)
|
|
}
|
|
if req.Status == adminStatusSYBWritebackSucceeded {
|
|
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
|
|
} else if req.Status != "" {
|
|
query = query.Where("status = ?", req.Status)
|
|
}
|
|
if req.ExecutionMode != "" {
|
|
query = query.Where("execution_mode = ?", req.ExecutionMode)
|
|
}
|
|
if req.TaskType != "" {
|
|
query = query.Where("task_type = ?", req.TaskType)
|
|
}
|
|
if req.SYBProductID > 0 {
|
|
query = query.Where("syb_product_id = ?", req.SYBProductID)
|
|
}
|
|
if orderNo := strings.TrimSpace(req.ShopeeOrderNo); orderNo != "" {
|
|
query = query.Where("shopee_order_no_snapshot LIKE ?", "%"+orderNo+"%")
|
|
}
|
|
if orderNo := strings.TrimSpace(req.PDDOrderNo); orderNo != "" {
|
|
query = query.Where("pdd_order_no LIKE ?", "%"+orderNo+"%")
|
|
}
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return AdminListResponse{}, internal(err)
|
|
}
|
|
var tasks []models.PurchaseTask
|
|
if err := query.Order("created_at DESC, id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&tasks).Error; err != nil {
|
|
return AdminListResponse{}, internal(err)
|
|
}
|
|
deviceNames, err := loadDeviceNames(s.DB.WithContext(ctx), tasks)
|
|
if err != nil {
|
|
return AdminListResponse{}, err
|
|
}
|
|
matching, err := s.matchingViews(ctx, tasks)
|
|
if err != nil {
|
|
return AdminListResponse{}, err
|
|
}
|
|
imageSearchLinked, err := s.loadImageSearchLinked(ctx, tasks)
|
|
if err != nil {
|
|
return AdminListResponse{}, err
|
|
}
|
|
items := make([]AdminTaskItem, 0, len(tasks))
|
|
writebacks, err := s.OrderWritebackViews(ctx, tasks)
|
|
if err != nil {
|
|
return AdminListResponse{}, err
|
|
}
|
|
for _, task := range tasks {
|
|
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
|
item.OrderWriteback = writebacks[task.ID]
|
|
item.Matching = matching[task.ID]
|
|
if task.ShopeeProductID != nil {
|
|
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil
|
|
}
|
|
|
|
// loadImageSearchLinked bulk-loads the read-only "linked via image search, not
|
|
// manually verified" flag for purchase-task list/detail views (#279 item 5).
|
|
// It is display-only and must never influence eligibility or matching.
|
|
func (s *Service) loadImageSearchLinked(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]bool, error) {
|
|
result := make(map[uint64]bool)
|
|
ids := make([]uint64, 0, len(tasks))
|
|
seen := make(map[uint64]bool)
|
|
for _, task := range tasks {
|
|
if task.ShopeeProductID != nil && !seen[*task.ShopeeProductID] {
|
|
ids = append(ids, *task.ShopeeProductID)
|
|
seen[*task.ShopeeProductID] = true
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
return result, nil
|
|
}
|
|
var shopeeProducts []models.ShopeeProduct
|
|
if err := s.DB.WithContext(ctx).Select("id, image_search_linked").Where("id IN ?", ids).Find(&shopeeProducts).Error; err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
for _, shopee := range shopeeProducts {
|
|
result[shopee.ID] = shopee.ImageSearchLinked
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailResponse, error) {
|
|
var task models.PurchaseTask
|
|
if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return AdminDetailResponse{}, fail(CodeTaskNotFound, "采购任务不存在")
|
|
}
|
|
return AdminDetailResponse{}, internal(err)
|
|
}
|
|
deviceNames, err := loadDeviceNames(s.DB.WithContext(ctx), []models.PurchaseTask{task})
|
|
if err != nil {
|
|
return AdminDetailResponse{}, err
|
|
}
|
|
var attempts []models.PurchaseTaskAttempt
|
|
if err := s.DB.WithContext(ctx).Where("task_id = ?", task.ID).Order("attempt_number ASC, id ASC").Find(&attempts).Error; err != nil {
|
|
return AdminDetailResponse{}, internal(err)
|
|
}
|
|
items := make([]AdminAttemptItem, 0, len(attempts))
|
|
for _, attempt := range attempts {
|
|
items = append(items, AdminAttemptItem{
|
|
AttemptID: attempt.AttemptID, AttemptNumber: attempt.AttemptNumber, Phase: attempt.Phase,
|
|
Status: attempt.Status, DeviceID: attempt.DeviceID, RuleSnapshotHash: attempt.RuleSnapshotHash,
|
|
ResultType: attempt.ResultType, ErrorCode: attempt.ErrorCode, ErrorMessage: attempt.ErrorMessage,
|
|
StartedAt: attempt.StartedAt, FinishedAt: attempt.FinishedAt, CreatedAt: attempt.CreatedAt,
|
|
})
|
|
}
|
|
matching, matchErr := s.MatchingDetail(ctx, task.ID)
|
|
if matchErr != nil {
|
|
return AdminDetailResponse{}, matchErr
|
|
}
|
|
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
|
|
writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task})
|
|
if wbErr != nil {
|
|
return AdminDetailResponse{}, wbErr
|
|
}
|
|
item.OrderWriteback = writebacks[task.ID]
|
|
item.Matching = matching
|
|
imageSearchLinked, err := s.loadImageSearchLinked(ctx, []models.PurchaseTask{task})
|
|
if err != nil {
|
|
return AdminDetailResponse{}, err
|
|
}
|
|
if task.ShopeeProductID != nil {
|
|
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
|
|
}
|
|
return AdminDetailResponse{Task: item, Attempts: items, Matching: matching}, nil
|
|
}
|
|
|
|
func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) {
|
|
ids := make([]uint64, 0, len(tasks))
|
|
seen := make(map[uint64]bool)
|
|
for _, task := range tasks {
|
|
if task.DeviceID != nil && !seen[*task.DeviceID] {
|
|
ids = append(ids, *task.DeviceID)
|
|
seen[*task.DeviceID] = true
|
|
}
|
|
}
|
|
names := make(map[uint64]string, len(ids))
|
|
if len(ids) == 0 {
|
|
return names, nil
|
|
}
|
|
var devices []models.AgentDevice
|
|
if err := db.Select("id, name").Where("id IN ?", ids).Find(&devices).Error; err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
for _, device := range devices {
|
|
names[device.ID] = device.Name
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retry retryDecision) AdminTaskItem {
|
|
item := AdminTaskItem{
|
|
ID: task.ID, TaskType: task.TaskType, ExecutionMode: task.ExecutionMode, Status: task.Status,
|
|
SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID,
|
|
DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot,
|
|
ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot,
|
|
PDDGoodsIDSnapshot: task.PDDGoodsIDSnapshot, PDDTitleSnapshot: task.PDDTitleSnapshot,
|
|
TargetColorSnapshot: task.TargetColorSnapshot, TargetSizeSnapshot: task.TargetSizeSnapshot,
|
|
MappedColorSnapshot: task.MappedColorSnapshot, MappedSizeSnapshot: task.MappedSizeSnapshot,
|
|
SpecSource: task.SpecSource, Quantity: task.Quantity, ReferenceUnitPriceCent: task.ReferenceUnitPriceCent,
|
|
MinUnitPriceCent: task.MinUnitPriceCent, MaxUnitPriceCent: task.MaxUnitPriceCent, Currency: task.Currency,
|
|
PDDAccountRefSnapshot: task.PDDAccountRefSnapshot, AddressSuffix: task.AddressSuffix,
|
|
PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, PDDOrderAmountCent: task.PDDOrderAmountCent, IrreversibleAt: task.IrreversibleAt,
|
|
PaymentReviewStatus: task.PaymentReviewStatus, PaymentReviewedAt: task.PaymentReviewedAt,
|
|
TrackingNo: task.TrackingNo, TrackingCollectedAt: task.TrackingCollectedAt,
|
|
LogisticsStatus: task.LogisticsStatus, WritebackStatus: task.WritebackStatus, WritebackAt: task.WritebackAt,
|
|
RePurchaseAuthorizedAt: task.RePurchaseAuthorizedAt, RePurchaseConsumedAt: task.RePurchaseConsumedAt,
|
|
CancelledAt: task.CancelledAt, CancelReason: task.CancelReason, ErrorCode: task.ErrorCode,
|
|
ErrorMessage: task.ErrorMessage, Retryable: retry.Allowed,
|
|
RetryDisabledCode: retry.ReasonCode, RetryDisabledReason: retry.Reason,
|
|
StatusVersion: task.StatusVersion, StatusChangedAt: task.StatusChangedAt,
|
|
CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt,
|
|
}
|
|
if task.DeviceID != nil {
|
|
item.DeviceName = deviceNames[*task.DeviceID]
|
|
}
|
|
return item
|
|
}
|
|
|
|
func validPurchaseStatus(status string) bool {
|
|
switch status {
|
|
case models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending,
|
|
models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusRehearsalCompleted,
|
|
models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated,
|
|
models.PurchaseTaskStatusOrderResultUnknown, models.PurchaseTaskStatusFailed,
|
|
models.PurchaseTaskStatusCancelled:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Service) matchingViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]MatchingView, error) {
|
|
result := make(map[uint64]MatchingView, len(tasks))
|
|
ids := make([]uint64, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
ids = append(ids, task.ID)
|
|
result[task.ID] = resolvedMatchingView(task)
|
|
}
|
|
if len(ids) == 0 {
|
|
return result, nil
|
|
}
|
|
var works []models.PurchaseSpecMatchWorkItem
|
|
if err := s.DB.WithContext(ctx).Where("purchase_task_id IN ?", ids).Find(&works).Error; err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
for _, work := range works {
|
|
view, err := matchingViewFromWork(work)
|
|
if err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
result[work.PurchaseTaskID] = view
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) MatchingDetail(ctx context.Context, taskID uint64) (MatchingView, error) {
|
|
var task models.PurchaseTask
|
|
if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
|
|
return MatchingView{}, purchaseNotFound(err)
|
|
}
|
|
var work models.PurchaseSpecMatchWorkItem
|
|
if err := s.DB.WithContext(ctx).Where("purchase_task_id = ?", taskID).First(&work).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return resolvedMatchingView(task), nil
|
|
} else if err != nil {
|
|
return MatchingView{}, internal(err)
|
|
}
|
|
view, err := matchingViewFromWork(work)
|
|
if err != nil {
|
|
return MatchingView{}, internal(err)
|
|
}
|
|
return view, nil
|
|
}
|
|
|
|
func resolvedMatchingView(task models.PurchaseTask) MatchingView {
|
|
if task.SpecSource == "unresolved" {
|
|
return MatchingView{Status: "unresolved", Executable: true, Reason: "任务执行时需要规格探测"}
|
|
}
|
|
return MatchingView{Status: models.PurchaseMatchMatched, Executable: true, Reason: "规格已匹配"}
|
|
}
|
|
|
|
func matchingViewFromWork(work models.PurchaseSpecMatchWorkItem) (MatchingView, error) {
|
|
var snapshot matchFingerprint
|
|
if err := json.Unmarshal([]byte(work.InputSnapshotJSON), &snapshot); err != nil {
|
|
return MatchingView{}, err
|
|
}
|
|
view := MatchingView{Status: work.Status, Executable: work.Status == models.PurchaseMatchMatched, Reason: work.Reason, NextAttemptAt: work.NextAttemptAt, TargetColor: snapshot.TargetColor, TargetSize: snapshot.TargetSize, Colors: snapshot.Colors, Sizes: snapshot.Sizes}
|
|
if work.LastErrorCode != nil {
|
|
view.ReasonCode = *work.LastErrorCode
|
|
}
|
|
switch work.Status {
|
|
case models.PurchaseMatchPending, models.PurchaseMatchRunning:
|
|
view.NextAction = "wait"
|
|
case models.PurchaseMatchRetryWait:
|
|
view.NextAction = "wait_retry"
|
|
case models.PurchaseMatchManualRequired:
|
|
view.NextAction = "manual_or_retry"
|
|
case models.PurchaseMatchCancelled:
|
|
view.NextAction = "requeue"
|
|
}
|
|
return view, nil
|
|
}
|