fix: 采购管理批量重试改为原地重试选中任务,不再新建 (#303)
采购员勾选失败任务点重试,要的是这条任务本身再跑一次。原先 BatchRetry 一律 调用 Create 新建(CG-224 → CG-234),任务号变化,同一 SYB 明细的多次尝试分散在 多个任务上。 - BatchRetry 改为逐条调用既有 Reset:任务号不变,状态回到 pending,本次执行记入 purchase_task_attempt。Reset 的全部保护原样沿用——碰过下单边界、同一明细已有 更新任务、规格快照不完整均拒绝并返回原因,拒绝后不退回新建。 - 先识别重放再做资格预检:首次重试后任务已是 pending,先预检会把同一 requestId 的重复提交判为「只有失败任务可以重试」,破坏幂等。测试抓到后修正。 - 资格预检关闭设备占用检查,同一设备上勾选的多条可排队;真正的占用判断在 Reset 事务内,ensureDeviceFree 不把租约为空的 pending 计为占用。 - AgentRetry(替代商品已匹配、继续采购)改走 batchRetryCreate,保持新建:商品已 替换,原任务的商品与规格快照不能复用。 - 前端文案改为「已重试 / 第 N 次执行」。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
@@ -32,6 +32,8 @@ type BatchRetryItem struct {
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
ReasonCode string `json:"reasonCode,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// AttemptNumber 仅原地重试时有值:该任务本次是第几次执行(#303)。
|
||||
AttemptNumber int `json:"attemptNumber,omitempty"`
|
||||
}
|
||||
|
||||
type BatchRetryResponse struct {
|
||||
@@ -69,7 +71,71 @@ type preparedRetry struct {
|
||||
// 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.
|
||||
// BatchRetry re-executes the selected failed tasks in place (#303).
|
||||
//
|
||||
// `[必须]` 不新建任务。采购员在管理页勾选失败任务点「重试」,要的是这条任务本身
|
||||
// 再跑一次:任务号不变,尝试记入 purchase_task_attempt。安全判据完全复用 Reset——
|
||||
// 碰过下单边界、同一 SYB 明细已有更新任务、规格快照不完整的都会被拒绝并返回原因,
|
||||
// 拒绝后也不退回新建,否则等于绕过了 Reset 的保护。
|
||||
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))}
|
||||
for index, taskID := range ids {
|
||||
item := BatchRetryItem{SourceTaskID: taskID, SourceTaskNo: taskNumber(taskID)}
|
||||
var source models.PurchaseTask
|
||||
if queryErr := s.DB.WithContext(ctx).First(&source, taskID).Error; queryErr != nil {
|
||||
if !errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||||
return BatchRetryResponse{}, internal(queryErr)
|
||||
}
|
||||
item.ReasonCode, item.Reason = CodeTaskNotFound, "采购任务不存在或已被删除"
|
||||
response.Items[index] = item
|
||||
response.FailedCount++
|
||||
continue
|
||||
}
|
||||
itemRequestID := retryItemRequestID(req.RequestID, taskID)
|
||||
// `[必须]` 先识别重放,再做资格预检。第一次重试后任务已是 pending,
|
||||
// 若先预检会被判「只有失败任务可以重试」拒掉,同一 requestId 重复提交就
|
||||
// 不再幂等。已有对应尝试记录时直接交给 Reset,由它返回 Replayed。
|
||||
var replayAttempt models.PurchaseTaskAttempt
|
||||
replayErr := s.DB.WithContext(ctx).Where("attempt_id = ?", purchaseResetAttemptID(itemRequestID, taskID)).First(&replayAttempt).Error
|
||||
if replayErr != nil && !errors.Is(replayErr, gorm.ErrRecordNotFound) {
|
||||
return BatchRetryResponse{}, internal(replayErr)
|
||||
}
|
||||
isReplay := replayErr == nil
|
||||
// 预检与原批量入口一致(状态、档案可采购性、设备),但 checkDeviceBusy 关闭:
|
||||
// 同一设备上勾选的多条任务要能排队,真正的占用判断在 Reset 的事务里做。
|
||||
if decision := s.retryEligibility(ctx, source, false); !isReplay && !decision.Allowed {
|
||||
item.ReasonCode, item.Reason = decision.ReasonCode, decision.Reason
|
||||
response.Items[index] = item
|
||||
response.FailedCount++
|
||||
continue
|
||||
}
|
||||
reset, resetErr := s.Reset(ctx, taskID, PurchaseResetRequest{RequestID: itemRequestID})
|
||||
if resetErr != nil {
|
||||
item.ReasonCode, item.Reason, _ = serviceErrorFields(resetErr)
|
||||
response.Items[index] = item
|
||||
response.FailedCount++
|
||||
continue
|
||||
}
|
||||
sameID := taskID
|
||||
item.Created, item.TaskID, item.TaskNo, item.Replayed = true, &sameID, taskNumber(taskID), reset.Replayed
|
||||
item.AttemptNumber = reset.AttemptNumber
|
||||
response.Items[index] = item
|
||||
response.CreatedCount++
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// batchRetryCreate creates replacement tasks. Only AgentRetry uses it: once a
|
||||
// replacement product has been matched the original task cannot be reused,
|
||||
// because its product and specification snapshots describe the old product.
|
||||
func (s *Service) batchRetryCreate(ctx context.Context, req BatchRetryRequest) (BatchRetryResponse, error) {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil {
|
||||
return BatchRetryResponse{}, fail(CodeInvalidRequest, "requestId 无效")
|
||||
}
|
||||
@@ -179,7 +245,7 @@ func (s *Service) AgentRetry(ctx context.Context, taskID uint64, req AgentRetryR
|
||||
return AgentRetryResponse{}, fail(decision.ReasonCode, decision.Reason)
|
||||
}
|
||||
}
|
||||
result, err := s.BatchRetry(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}})
|
||||
result, err := s.batchRetryCreate(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}})
|
||||
if err != nil {
|
||||
return AgentRetryResponse{}, err
|
||||
}
|
||||
|
||||
@@ -36,13 +36,16 @@ func failedLiveTask(t *testing.T, db *gorm.DB, service *Service, f fixture) mode
|
||||
return task
|
||||
}
|
||||
|
||||
func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
|
||||
// `[必须]` #303:采购管理页勾选失败任务点重试,要的是这条任务本身再跑一次——
|
||||
// 任务号不变、不产生新任务,本次执行记入 purchase_task_attempt。
|
||||
func TestBatchRetryReexecutesSelectedTaskInPlaceAndReplays(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)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("order_code", "SYB-CHANGED").Error; err != nil {
|
||||
var before int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Count(&before).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}
|
||||
@@ -51,36 +54,69 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
|
||||
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 {
|
||||
if first.CreatedCount != 1 || first.FailedCount != 0 || len(first.Items) != 1 || 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])
|
||||
if *first.Items[0].TaskID != failed.ID {
|
||||
t.Fatalf("retry must reuse the selected task, got %+v", first.Items[0])
|
||||
}
|
||||
var oldTask, newTask models.PurchaseTask
|
||||
if err = db.First(&oldTask, failed.ID).Error; err != nil {
|
||||
var after int64
|
||||
if err := db.Model(&models.PurchaseTask{}).Count(&after).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.First(&newTask, *first.Items[0].TaskID).Error; err != nil {
|
||||
if after != before {
|
||||
t.Fatalf("retry created a new purchase task: before=%d after=%d", before, after)
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if err = db.First(&task, failed.ID).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)
|
||||
if task.Status != models.PurchaseTaskStatusPending || task.ErrorCode != nil || task.AddressSuffix != failed.AddressSuffix {
|
||||
t.Fatalf("task was not reset in place: %+v", task)
|
||||
}
|
||||
if newTask.ShopeeOrderNoSnapshot != failed.ShopeeOrderNoSnapshot {
|
||||
t.Fatalf("retry changed order snapshot: old=%q new=%q", failed.ShopeeOrderNoSnapshot, newTask.ShopeeOrderNoSnapshot)
|
||||
if first.Items[0].AttemptNumber < 1 {
|
||||
t.Fatalf("attempt number missing: %+v", first.Items[0])
|
||||
}
|
||||
|
||||
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 {
|
||||
if replay.CreatedCount != 1 || !replay.Items[0].Replayed || *replay.Items[0].TaskID != failed.ID || replay.Items[0].AttemptNumber != first.Items[0].AttemptNumber {
|
||||
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)
|
||||
}
|
||||
|
||||
// `[必须]` 碰过下单边界的失败任务可能已在 PDD 建了订单,必须拒绝,而且拒绝后
|
||||
// 不能退回新建——否则等于绕过了 Reset 的保护,重复下单。
|
||||
func TestBatchRetryRefusesTaskPastOrderBoundaryWithoutCreating(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)
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Update("irreversible_at", service.Now()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var before int64
|
||||
db.Model(&models.PurchaseTask{}).Count(&before)
|
||||
|
||||
result, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CreatedCount != 0 || result.FailedCount != 1 || result.Items[0].ReasonCode == "" {
|
||||
t.Fatalf("task past the order boundary must be refused: %+v", result)
|
||||
}
|
||||
var after int64
|
||||
db.Model(&models.PurchaseTask{}).Count(&after)
|
||||
if after != before {
|
||||
t.Fatalf("refused retry must not create a task: before=%d after=%d", before, after)
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
db.First(&task, failed.ID)
|
||||
if task.Status != models.PurchaseTaskStatusFailed {
|
||||
t.Fatalf("refused task changed status: %q", task.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +210,8 @@ func TestAdminListRetryEligibilityRejectsStaleAndOfflineFailures(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}})
|
||||
// 用仍会新建的替代路径制造「同一明细已有更新任务」的前提。
|
||||
created, err := service.batchRetryCreate(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)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="retryDialog.open" title="确认重试失败任务" width="700px" :close-on-click-modal="false" :close-on-press-escape="!retryDialog.saving" :show-close="!retryDialog.saving">
|
||||
<el-alert title="系统将根据当前商品资料、规格映射和最新采购规则创建新任务。原失败任务和执行记录会保留;本操作不会支付。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-alert title="将直接重新执行所选任务,任务号不变,不会创建新任务;每次执行都会保留记录。已进入下单环节的任务可能已生成订单,系统会拒绝重试。本操作不会支付。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-table :data="retryDialog.items" border size="small" max-height="320">
|
||||
<el-table-column label="失败任务" width="105"><template #default="{ row }"><strong>CG-{{ row.id }}</strong></template></el-table-column>
|
||||
<el-table-column label="SYB / 虾皮商品" min-width="240"><template #default="{ row }"><div class="primary ellipsis">{{ row.shopeeTitleSnapshot || `虾皮商品 ${row.shopeeItemIdSnapshot || '—'}` }}</div><div class="muted">SYB-{{ row.sybProductId || '—' }}</div></template></el-table-column>
|
||||
@@ -103,11 +103,11 @@
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="retryResult.open" title="批量重试结果" width="760px" :close-on-click-modal="false">
|
||||
<el-alert :title="`已创建 ${retryResult.createdCount} 条,未创建 ${retryResult.failedCount} 条。失败项不会影响已成功任务。`" :type="retryResult.failedCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" />
|
||||
<el-alert :title="`已重试 ${retryResult.createdCount} 条,未重试 ${retryResult.failedCount} 条。失败项不会影响已成功任务。`" :type="retryResult.failedCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" />
|
||||
<el-table :data="retryResult.items" border size="small" max-height="360">
|
||||
<el-table-column label="原任务" width="105"><template #default="{ row }">{{ row.sourceTaskNo || `CG-${row.sourceTaskId}` }}</template></el-table-column>
|
||||
<el-table-column label="结果" width="100" align="center"><template #default="{ row }"><el-tag :type="row.created ? 'success' : 'danger'">{{ row.created ? '已创建' : '未创建' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="新任务 / 说明" min-width="360"><template #default="{ row }"><el-button v-if="row.created && row.taskId" type="primary" link @click="openRetriedTask(row)">{{ row.taskNo || `CG-${row.taskId}` }} · 待执行</el-button><span v-else>{{ row.reason || '未创建,请刷新后重试' }}</span><span v-if="row.replayed" class="muted">(请求已处理)</span></template></el-table-column>
|
||||
<el-table-column label="结果" width="100" align="center"><template #default="{ row }"><el-tag :type="row.created ? 'success' : 'danger'">{{ row.created ? '已重试' : '未重试' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="任务 / 说明" min-width="360"><template #default="{ row }"><el-button v-if="row.created && row.taskId" type="primary" link @click="openRetriedTask(row)">{{ row.taskNo || `CG-${row.taskId}` }} · 待执行<template v-if="row.attemptNumber">(第 {{ row.attemptNumber }} 次执行)</template></el-button><span v-else>{{ row.reason || '未重试,请刷新后重试' }}</span><span v-if="row.replayed" class="muted">(请求已处理)</span></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button @click="retryResult.open=false">留在当前页</el-button><el-button v-if="firstRetriedTask" type="primary" @click="openRetriedTask(firstRetriedTask)">查看成功任务</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
Reference in New Issue
Block a user