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

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
}