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

449 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package purchase
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"unicode"
"unicode/utf8"
"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
ShopeeOrderNos []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"
// A nil list means the optional filter was absent; an explicitly empty list
// must not silently turn a restricted lookup into an unfiltered query.
func normalizeAdminOrderNos(single string, raw []string) ([]string, error) {
single = strings.TrimSpace(single)
if utf8.RuneCountInString(single) > 64 {
return nil, fail(CodeInvalidRequest, "每个蝦皮订单号最多 64 个字符")
}
if raw == nil {
return nil, nil
}
if single != "" {
return nil, fail(CodeInvalidRequest, "蝦皮订单号单值与批量参数不能同时使用")
}
values := make([]string, 0, len(raw))
seen := make(map[string]bool)
for _, entry := range raw {
for _, value := range strings.FieldsFunc(entry, func(r rune) bool {
return r == ',' || r == ',' || unicode.IsSpace(r) || r == '\uFEFF'
}) {
if utf8.RuneCountInString(value) > 64 {
return nil, fail(CodeInvalidRequest, "每个蝦皮订单号最多 64 个字符")
}
if !seen[value] {
seen[value] = true
values = append(values, value)
if len(values) > 100 {
return nil, fail(CodeInvalidRequest, "最多查询 100 个蝦皮订单号,请分批查询")
}
}
}
}
if len(values) == 0 {
return nil, fail(CodeInvalidRequest, "批量蝦皮订单号不能为空")
}
return values, nil
}
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
orderNos, err := normalizeAdminOrderNos(req.ShopeeOrderNo, req.ShopeeOrderNos)
if err != nil {
return AdminListResponse{}, err
}
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 len(orderNos) > 0 {
query = query.Where("shopee_order_no_snapshot IN ?", orderNos)
}
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
}