From 01d852011a781c5046a64f83bced7b7f6a9ddec6 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 21 Aug 2026 14:20:40 +0800 Subject: [PATCH] feat(#58): batch retry failed purchase tasks --- docs/02-architecture-and-code-map.md | 5 +- docs/03-business-rules-and-glossary.md | 7 +- server/app/goauto/purchase/admin_query.go | 13 +- .../app/goauto/purchase/admin_query_test.go | 2 +- server/app/goauto/purchase/handler.go | 20 ++ server/app/goauto/purchase/manual.go | 2 +- server/app/goauto/purchase/retry.go | 197 ++++++++++++++++++ server/app/goauto/purchase/retry_test.go | 192 +++++++++++++++++ server/app/goauto/purchase/router.go | 1 + server/app/goauto/purchase/types.go | 3 + web/src/api/goauto/purchase-tasks.js | 1 + web/src/views/goauto/purchase-tasks/index.vue | 59 +++++- 12 files changed, 484 insertions(+), 18 deletions(-) create mode 100644 server/app/goauto/purchase/retry.go create mode 100644 server/app/goauto/purchase/retry_test.go diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index c6980ec..3bad400 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: a582b9ed5712d0c1fceeb5530f60a3d746cbbd24 -synchronized_at: 2026-08-21T03:03:32Z +wiki_revision: 86f5a5432ee1683b466e87d127768239d3492b54 +synchronized_at: 2026-08-21T06:18:32Z # 架构与代码地图 @@ -124,6 +124,7 @@ Android Portal/Agent | 管理端基线 | `web/`(go-admin-ui v3.0.0) | | 管理端闭环页面 | `web/src/views/goauto/` | | Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 | +| Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent | | SYB 店铺管理页面与接口封装 | `web/src/views/goauto/syb-shops/`、`web/src/api/goauto/syb-shops.js`;确认原型快照 `prototypes/49/v1/index.html` | | SYB 异步导入、当前页采购选择/确认/逐条结果与同步记录页面 | `web/src/views/goauto/syb-products/`、`web/src/views/goauto/syb-sync-runs/`、`web/src/api/goauto/syb-products.js`、`web/src/api/goauto/purchase-tasks.js`;确认原型见 #44 设计证据,导入原型快照为 `prototypes/50/v2/index.html` | | Android Agent 基线 | `android/app/src/main/java/cn/ilapage/goauto/agent/` | diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index adb92a0..e86e211 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: f0e414f176cd42e0ec79fcabe46c5497e4294e9a -synchronized_at: 2026-08-21T03:03:36Z +wiki_revision: cd1b56cf3edd6a6a88b2de0c9a174136a3c90132 +synchronized_at: 2026-08-21T06:18:36Z # 业务规则与术语 @@ -127,6 +127,9 @@ synchronized_at: 2026-08-21T03:03:36Z - 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。 - 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。 - Admin 采购管理只查看和处理已有任务,不提供创建入口或支付按钮;单条和批量采购任务都从 SYB 商品列表发起。订单结果未知时必须先人工核对并解除;处于该状态时页面不提供重新采购授权。 +- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、当前规格映射、当前价格保护和最新内置采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀。 +- 只有正式采购、未进入不可逆边界、没有订单号或下单时间、且仍是同一 SYB 最新记录的 `failed` 任务可重试。原设备离线、停用、忙碌或能力不足时该项失败且不自动换机;未指定设备时仍由空闲设备领取。 +- 批量重试逐项处理并允许部分成功;同一请求幂等重放不会重复创建。失败任务不再使用一次性重新采购授权,该授权只保留给已经创建过订单且满足条件的任务。 ## 自动化边界 diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go index f56c5fc..e7b86d4 100644 --- a/server/app/goauto/purchase/admin_query.go +++ b/server/app/goauto/purchase/admin_query.go @@ -63,6 +63,9 @@ type AdminTaskItem struct { 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"` @@ -142,7 +145,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis } items := make([]AdminTaskItem, 0, len(tasks)) for _, task := range tasks { - items = append(items, adminTaskItem(task, deviceNames)) + items = append(items, adminTaskItem(task, deviceNames, s.retryEligibility(ctx, task, true))) } return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil } @@ -172,7 +175,7 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe StartedAt: attempt.StartedAt, FinishedAt: attempt.FinishedAt, CreatedAt: attempt.CreatedAt, }) } - return AdminDetailResponse{Task: adminTaskItem(task, deviceNames), Attempts: items}, nil + return AdminDetailResponse{Task: adminTaskItem(task, deviceNames, s.retryEligibility(ctx, task, true)), Attempts: items}, nil } func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) { @@ -198,7 +201,7 @@ func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]strin return names, nil } -func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string) AdminTaskItem { +func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retry retryDecision) AdminTaskItem { item := AdminTaskItem{ ID: task.ID, ExecutionMode: task.ExecutionMode, Status: task.Status, SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID, @@ -216,7 +219,9 @@ func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string) Admi 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, StatusVersion: task.StatusVersion, StatusChangedAt: task.StatusChangedAt, + 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 { diff --git a/server/app/goauto/purchase/admin_query_test.go b/server/app/goauto/purchase/admin_query_test.go index fc18539..88c7f6d 100644 --- a/server/app/goauto/purchase/admin_query_test.go +++ b/server/app/goauto/purchase/admin_query_test.go @@ -104,7 +104,7 @@ func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) { func TestAdminBatchHandlersRequireOperatorRole(t *testing.T) { gin.SetMode(gin.TestMode) - for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate} { + for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry} { recorder := httptest.NewRecorder() context, _ := gin.CreateTestContext(recorder) context.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/batch", strings.NewReader(`{}`)) diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index cbe5830..776926b 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -139,6 +139,26 @@ func (h Handler) AdminBatchCreate(c *gin.Context) { } writeAdminData(c, response) } + +func (h Handler) AdminBatchRetry(c *gin.Context) { + if !allowedOperator(c) { + return + } + var req BatchRetryRequest + if !decode(c, &req) { + return + } + service, ok := h.service(c) + if !ok { + return + } + response, err := service.BatchRetry(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeAdminData(c, response) +} func (h Handler) Next(c *gin.Context) { service, ok := h.service(c) if !ok { diff --git a/server/app/goauto/purchase/manual.go b/server/app/goauto/purchase/manual.go index 01580db..5256aab 100644 --- a/server/app/goauto/purchase/manual.go +++ b/server/app/goauto/purchase/manual.go @@ -12,7 +12,7 @@ import ( func (s *Service) AuthorizeRePurchase(ctx context.Context, id uint64, req ManualRequest) (models.PurchaseTask, bool, error) { return s.manual(ctx, id, req, func(t *models.PurchaseTask) error { - if t.Status != models.PurchaseTaskStatusOrderCreated && t.Status != models.PurchaseTaskStatusFailed && t.Status != models.PurchaseTaskStatusCancelled { + if t.Status != models.PurchaseTaskStatusOrderCreated && t.Status != models.PurchaseTaskStatusCancelled { return fail(CodeStateConflict, "当前任务不能授权重新采购") } if t.Status == models.PurchaseTaskStatusOrderCreated && t.PaymentReviewStatus == models.PurchasePaymentReviewPaid { diff --git a/server/app/goauto/purchase/retry.go b/server/app/goauto/purchase/retry.go new file mode 100644 index 0000000..29d0e1c --- /dev/null +++ b/server/app/goauto/purchase/retry.go @@ -0,0 +1,197 @@ +package purchase + +import ( + "context" + "errors" + "fmt" + "strings" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const maxBatchRetryItems = 100 + +type BatchRetryRequest struct { + RequestID string `json:"requestId"` + TaskIDs []uint64 `json:"taskIds"` +} + +type BatchRetryItem struct { + SourceTaskID uint64 `json:"sourceTaskId"` + SourceTaskNo string `json:"sourceTaskNo"` + Created bool `json:"created"` + TaskID *uint64 `json:"taskId,omitempty"` + TaskNo string `json:"taskNo,omitempty"` + Replayed bool `json:"replayed,omitempty"` + ReasonCode string `json:"reasonCode,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type BatchRetryResponse struct { + Items []BatchRetryItem `json:"items"` + CreatedCount int `json:"createdCount"` + FailedCount int `json:"failedCount"` +} + +type retryDecision struct { + Allowed bool + ReasonCode string + Reason string + Preview BatchPreviewItem +} + +type preparedRetry struct { + resultIndex int + source models.PurchaseTask + preview BatchPreviewItem + requestID string +} + +// BatchRetry creates new pending purchase tasks from failed task identities. +// The failed rows remain immutable history; the current product, mapping, +// price guard and server-owned rule are resolved again at retry time. +func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchRetryResponse, error) { + if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil { + return BatchRetryResponse{}, fail(CodeInvalidRequest, "requestId 无效") + } + ids, err := validateRetryTaskIDs(req.TaskIDs) + if err != nil { + return BatchRetryResponse{}, err + } + + response := BatchRetryResponse{Items: make([]BatchRetryItem, len(ids))} + prepared := make([]preparedRetry, 0, len(ids)) + for index, sourceTaskID := range ids { + item := BatchRetryItem{SourceTaskID: sourceTaskID, SourceTaskNo: taskNumber(sourceTaskID)} + itemRequestID := retryItemRequestID(req.RequestID, sourceTaskID) + var replay models.PurchaseTask + if queryErr := s.DB.WithContext(ctx).Where("create_request_id = ?", itemRequestID).First(&replay).Error; queryErr == nil { + newTaskID := replay.ID + item.Created, item.TaskID, item.TaskNo, item.Replayed = true, &newTaskID, taskNumber(newTaskID), true + response.Items[index] = item + response.CreatedCount++ + continue + } else if !errors.Is(queryErr, gorm.ErrRecordNotFound) { + return BatchRetryResponse{}, internal(queryErr) + } + + var source models.PurchaseTask + if queryErr := s.DB.WithContext(ctx).First(&source, sourceTaskID).Error; queryErr != nil { + item.ReasonCode, item.Reason = CodeTaskNotFound, "采购任务不存在或已被删除" + response.Items[index] = item + response.FailedCount++ + continue + } + decision := s.retryEligibility(ctx, source, true) + if !decision.Allowed { + item.ReasonCode, item.Reason = decision.ReasonCode, decision.Reason + response.Items[index] = item + response.FailedCount++ + continue + } + response.Items[index] = item + prepared = append(prepared, preparedRetry{resultIndex: index, source: source, preview: decision.Preview, requestID: itemRequestID}) + } + + // Eligibility is collected before any new task is written. This lets a + // single free device receive multiple selected retries as a serial queue, + // while a device that was already occupied before the batch is rejected. + for _, candidate := range prepared { + sybID := candidate.preview.SYBProductID + task, replayed, createErr := s.Create(ctx, CreateRequest{ + RequestID: candidate.requestID, ExecutionMode: models.PurchaseExecutionModeLive, + SYBProductID: &sybID, DeviceID: candidate.source.DeviceID, + ReferenceUnitPriceCent: candidate.preview.ReferenceUnitPriceCent, + MinUnitPriceCent: candidate.preview.MinUnitPriceCent, + MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent, + Currency: candidate.preview.Currency, + RuleSnapshot: purchasecontract.DefaultLiveRule(), + }) + item := response.Items[candidate.resultIndex] + if createErr != nil { + item.ReasonCode, item.Reason, _ = serviceErrorFields(createErr) + response.Items[candidate.resultIndex] = item + response.FailedCount++ + continue + } + newTaskID := task.ID + item.Created, item.TaskID, item.TaskNo, item.Replayed = true, &newTaskID, taskNumber(newTaskID), replayed + response.Items[candidate.resultIndex] = item + response.CreatedCount++ + } + return response, nil +} + +func validateRetryTaskIDs(raw []uint64) ([]uint64, error) { + if len(raw) == 0 || len(raw) > maxBatchRetryItems { + return nil, fail(CodeInvalidRequest, "taskIds 必须包含 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, "taskIds 包含无效或重复任务") + } + seen[id] = true + ids = append(ids, id) + } + return ids, nil +} + +func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask, checkDeviceBusy bool) retryDecision { + deny := func(code, message string) retryDecision { + return retryDecision{ReasonCode: code, Reason: message} + } + if task.Status != models.PurchaseTaskStatusFailed { + return deny(CodeRetryNotAllowed, "只有采购失败任务可以重试") + } + if task.ExecutionMode != models.PurchaseExecutionModeLive || task.SYBProductID == nil { + return deny(CodeRetryNotAllowed, "只有正式采购的失败任务可以重试") + } + if task.IrreversibleAt != nil || task.OrderSubmitRequestID != nil || task.PDDOrderNo != nil || task.OrderSubmittedAt != nil { + return deny(CodeRetryUnsafe, "任务可能已经创建订单,请先人工核对,不能直接重试") + } + + var latest models.PurchaseTask + if err := s.DB.WithContext(ctx).Where("syb_product_id = ?", *task.SYBProductID).Order("id DESC").First(&latest).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return deny(CodeTaskNotFound, "采购任务不存在或已被删除") + } + return deny(CodeInternal, "服务端处理失败") + } + if latest.ID != task.ID { + return deny(CodeRetryStale, fmt.Sprintf("同一 SYB 商品已有更新任务 %s", taskNumber(latest.ID))) + } + + preview := s.previewOne(ctx, *task.SYBProductID) + if !preview.Eligible { + return deny(preview.ReasonCode, preview.Reason) + } + if task.DeviceID != nil { + if err := s.validateBatchDevice(ctx, task.DeviceID); err != nil { + code, message, _ := serviceErrorFields(err) + if code == CodeInvalidRequest { + message = "原设备当前不可用,请先恢复设备" + } + return deny(code, message) + } + if checkDeviceBusy { + if err := ensureDeviceFree(s.DB.WithContext(ctx), *task.DeviceID, 0, s.Now()); err != nil { + code, message, _ := serviceErrorFields(err) + if code == CodeDeviceBusy { + message = "原设备已有其他任务,请稍后重试" + } + return deny(code, message) + } + } + } + return retryDecision{Allowed: true, Preview: preview} +} + +func retryItemRequestID(batchRequestID string, sourceTaskID uint64) string { + return uuid.NewSHA1(uuid.NameSpaceOID, []byte(fmt.Sprintf("purchase-retry:%s:%d", batchRequestID, sourceTaskID))).String() +} diff --git a/server/app/goauto/purchase/retry_test.go b/server/app/goauto/purchase/retry_test.go new file mode 100644 index 0000000..e439b4b --- /dev/null +++ b/server/app/goauto/purchase/retry_test.go @@ -0,0 +1,192 @@ +package purchase + +import ( + "context" + "strings" + "testing" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +func failedLiveTask(t *testing.T, db *gorm.DB, service *Service, f fixture) models.PurchaseTask { + t.Helper() + task, err := createLive(t, service, f) + if err != nil { + t.Fatal(err) + } + if err = task.SetStatus(models.PurchaseTaskStatusFailed); err != nil { + t.Fatal(err) + } + code, message := "TEST_FAILURE", "测试失败" + task.ErrorCode, task.ErrorMessage = &code, &message + if err = db.Save(&task).Error; err != nil { + t.Fatal(err) + } + return task +} + +func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + request := BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}} + + first, err := service.BatchRetry(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if first.CreatedCount != 1 || first.FailedCount != 0 || len(first.Items) != 1 || !first.Items[0].Created || first.Items[0].TaskID == nil { + t.Fatalf("unexpected retry result: %+v", first) + } + if *first.Items[0].TaskID == failed.ID { + t.Fatalf("retry reused failed task: %+v", first.Items[0]) + } + var oldTask, newTask models.PurchaseTask + if err = db.First(&oldTask, failed.ID).Error; err != nil { + t.Fatal(err) + } + if err = db.First(&newTask, *first.Items[0].TaskID).Error; err != nil { + t.Fatal(err) + } + if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.AddressSuffix == failed.AddressSuffix || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID { + t.Fatalf("retry facts mismatch: old=%+v new=%+v", oldTask, newTask) + } + + replay, err := service.BatchRetry(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if replay.CreatedCount != 1 || !replay.Items[0].Replayed || replay.Items[0].TaskID == nil || *replay.Items[0].TaskID != newTask.ID { + t.Fatalf("retry replay was not stable: first=%+v replay=%+v", first, replay) + } + var count int64 + if err = db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", f.syb.ID).Count(&count).Error; err != nil || count != 2 { + t.Fatalf("retry created duplicate tasks: count=%d err=%v", count, err) + } +} + +func TestBatchRetryKeepsPartialSuccessAndRejectsUnsafeTask(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + secondSYB := f.syb + secondSYB.ID = 0 + secondSYB.OrderCode = "SYB-2" + secondSYB.DetailID++ + if err := db.Create(&secondSYB).Error; err != nil { + t.Fatal(err) + } + service := testService(db) + firstFailed := failedLiveTask(t, db, service, f) + secondFixture := f + secondFixture.syb = secondSYB + unsafe := failedLiveTask(t, db, service, secondFixture) + irreversible := service.Now() + unsafe.IrreversibleAt = &irreversible + if err := db.Save(&unsafe).Error; err != nil { + t.Fatal(err) + } + + result, err := service.BatchRetry(context.Background(), BatchRetryRequest{ + RequestID: uuid.NewString(), TaskIDs: []uint64{firstFailed.ID, unsafe.ID}, + }) + if err != nil { + t.Fatal(err) + } + if result.CreatedCount != 1 || result.FailedCount != 1 || !result.Items[0].Created || result.Items[1].ReasonCode != CodeRetryUnsafe { + t.Fatalf("partial retry result mismatch: %+v", result) + } +} + +func TestAdminListRetryEligibilityRejectsStaleAndOfflineFailures(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + + list, err := service.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, TaskID: failed.ID}) + if err != nil { + t.Fatal(err) + } + if len(list.Items) != 1 || !list.Items[0].Retryable || list.Items[0].RetryDisabledReason != "" { + t.Fatalf("safe failed task was not retryable: %+v", list.Items) + } + + if err = db.Model(&models.AgentDevice{}).Where("id = ?", f.device.ID).Update("status", models.DeviceStatusOffline).Error; err != nil { + t.Fatal(err) + } + list, err = service.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, TaskID: failed.ID}) + if err != nil { + t.Fatal(err) + } + if list.Items[0].Retryable || !strings.Contains(list.Items[0].RetryDisabledReason, "原设备") { + t.Fatalf("offline failed task was selectable: %+v", list.Items[0]) + } + if err = db.Model(&models.AgentDevice{}).Where("id = ?", f.device.ID).Update("status", models.DeviceStatusOnline).Error; err != nil { + t.Fatal(err) + } + + created, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}) + if err != nil || created.CreatedCount != 1 { + t.Fatalf("create newer task: %+v %v", created, err) + } + list, err = service.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, TaskID: failed.ID}) + if err != nil { + t.Fatal(err) + } + if list.Items[0].Retryable || list.Items[0].RetryDisabledCode != CodeRetryStale || !strings.Contains(list.Items[0].RetryDisabledReason, "更新任务") { + t.Fatalf("stale failed task was selectable: %+v", list.Items[0]) + } +} + +func TestAdminListRetryEligibilityRejectsBusyOriginalDevice(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + + otherSYB := f.syb + otherSYB.ID = 0 + otherSYB.OrderCode = "SYB-BUSY" + otherSYB.DetailID++ + if err := db.Create(&otherSYB).Error; err != nil { + t.Fatal(err) + } + otherFixture := f + otherFixture.syb = otherSYB + busyTask, err := createLive(t, service, otherFixture) + if err != nil { + t.Fatal(err) + } + if _, err = service.Claim(context.Background(), busyTask.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + if _, err = service.Start(context.Background(), busyTask.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + + list, err := service.AdminList(context.Background(), AdminListRequest{Page: 1, PageSize: 20, TaskID: failed.ID}) + if err != nil { + t.Fatal(err) + } + if len(list.Items) != 1 || list.Items[0].Retryable || !strings.Contains(list.Items[0].RetryDisabledReason, "原设备已有其他任务") { + t.Fatalf("busy device failure was selectable: %+v", list.Items) + } +} + +func TestFailedTaskNoLongerNeedsRepurchaseAuthorization(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + task := failedLiveTask(t, db, testService(db), f) + _, _, err := testService(db).AuthorizeRePurchase(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1}) + if code(err) != CodeStateConflict { + t.Fatalf("failed task accepted redundant authorization: %v", err) + } +} diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index 81aa60e..2d539a7 100644 --- a/server/app/goauto/purchase/router.go +++ b/server/app/goauto/purchase/router.go @@ -25,6 +25,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { admin.GET("", h.AdminList) admin.POST("/batch-preview", h.AdminBatchPreview) admin.POST("/batch", h.AdminBatchCreate) + admin.POST("/batch-retry", h.AdminBatchRetry) admin.GET("/:taskId", h.AdminDetail) admin.POST("", h.AdminCreate) admin.POST("/:taskId/spec-decision", h.SpecDecision) diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 394445c..81e9855 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -17,6 +17,9 @@ const ( CodeMappingRequired = "PURCHASE_SPEC_MAPPING_REQUIRED" CodeResultConflict = "PURCHASE_RESULT_CONFLICT" CodeRePurchaseRequired = "REPURCHASE_AUTHORIZATION_REQUIRED" + CodeRetryNotAllowed = "PURCHASE_RETRY_NOT_ALLOWED" + CodeRetryUnsafe = "PURCHASE_RETRY_UNSAFE" + CodeRetryStale = "PURCHASE_RETRY_STALE" CodeInternal = "INTERNAL_ERROR" ) diff --git a/web/src/api/goauto/purchase-tasks.js b/web/src/api/goauto/purchase-tasks.js index 767bc3e..f10a629 100644 --- a/web/src/api/goauto/purchase-tasks.js +++ b/web/src/api/goauto/purchase-tasks.js @@ -4,6 +4,7 @@ export function listPurchaseTasks(params) { return request({ url: '/api/admin/v1 export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) } export function previewPurchaseTasks(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data }) } export function createPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch', method: 'post', data }) } +export function retryPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-retry', method: 'post', data }) } export function authorizeRepurchase(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/authorize-repurchase`, method: 'post', data }) } export function reviewPurchasePayment(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/payment-review`, method: 'post', data }) } export function selectPurchaseWriteback(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/writeback-candidate`, method: 'post', data }) } diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index 3698696..9306504 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -9,15 +9,17 @@ 查询重置 + 已选择 {{ retrySelection.length }} 条重试({{ retrySelection.length }}) - + + CG-{{ row.id }}{{ modeLabel(row.executionMode) }} {{ row.shopeeTitleSnapshot || `虾皮商品 ${row.shopeeItemIdSnapshot || '—'}` }}SYB-{{ row.sybProductId || '—' }} · {{ row.shopeeShopNameSnapshot || '店铺未记录' }} {{ row.pddTitleSnapshot || '标题未记录' }}goods_id {{ row.pddGoodsIdSnapshot || '—' }} {{ specText(row.mappedColorSnapshot, row.mappedSizeSnapshot) }}目标:{{ specText(row.targetColorSnapshot, row.targetSizeSnapshot) }} · {{ row.quantity }} 件 {{ row.deviceName || '尚未分派' }}账号:{{ row.pddAccountRefSnapshot || '不强制记录' }} - {{ statusLabel(row.status) }} + {{ statusLabel(row.status) }}不可重试 {{ row.pddOrderNo || '尚未取得订单号' }}{{ paymentLabel(row.paymentReviewStatus) }} {{ formatTime(row.createdAt) }} 详情 @@ -71,6 +73,27 @@ 取消确认授权 + + + + CG-{{ row.id }} + {{ row.shopeeTitleSnapshot || `虾皮商品 ${row.shopeeItemIdSnapshot || '—'}` }}SYB-{{ row.sybProductId || '—' }} + {{ row.deviceName || '空闲设备领取' }} + + 共选择 {{ retryDialog.items.length }} 条。原任务指定设备时继续沿用;未指定设备时由空闲设备领取。 + 取消确认重试 + + + + + + {{ row.sourceTaskNo || `CG-${row.sourceTaskId}` }} + {{ row.created ? '已创建' : '未创建' }} + {{ row.taskNo || `CG-${row.taskId}` }} · 待执行{{ row.reason || '未创建,请刷新后重试' }}(请求已处理) + + 留在当前页查看成功任务 + + @@ -92,7 +115,7 @@
共选择 {{ retryDialog.items.length }} 条。原任务指定设备时继续沿用;未指定设备时由空闲设备领取。