diff --git a/server/app/goauto/task/admin_handler.go b/server/app/goauto/task/admin_handler.go index 18acac3..98f7a6e 100644 --- a/server/app/goauto/task/admin_handler.go +++ b/server/app/goauto/task/admin_handler.go @@ -33,7 +33,7 @@ func (handler Handler) AdminList(c *gin.Context) { } response, err := service.AdminList(c.Request.Context(), AdminListRequest{ Page: page, PageSize: pageSize, Status: strings.TrimSpace(c.Query("status")), - GoodsID: strings.TrimSpace(c.Query("goodsId")), DeviceID: deviceID, + GoodsID: strings.TrimSpace(c.Query("goodsId")), Source: strings.TrimSpace(c.Query("source")), DeviceID: deviceID, }) if err != nil { writeError(c, err) diff --git a/server/app/goauto/task/admin_list_source_test.go b/server/app/goauto/task/admin_list_source_test.go new file mode 100644 index 0000000..fa922c5 --- /dev/null +++ b/server/app/goauto/task/admin_list_source_test.go @@ -0,0 +1,50 @@ +package task + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +// TestAdminListFiltersBySource 验证 #298 新增的 source 过滤:只返回指定来源的任务, +// 不影响其它来源。 +func TestAdminListFiltersBySource(t *testing.T) { + db := openTaskDatabase(t) + service := newTaskService(db) + + adminTask := createTask(t, db, nil) + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", adminTask.ID). + Update("source", models.CollectionTaskSourceAdmin).Error; err != nil { + t.Fatalf("set admin source: %v", err) + } + imageSearchTask := createTask(t, db, nil) + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", imageSearchTask.ID). + Update("source", models.CollectionTaskSourceImageSearch).Error; err != nil { + t.Fatalf("set image_search source: %v", err) + } + + response, err := service.AdminList(context.Background(), AdminListRequest{Source: models.CollectionTaskSourceAdmin}) + if err != nil { + t.Fatalf("admin list: %v", err) + } + if response.Total != 1 || len(response.Items) != 1 || response.Items[0].ID != adminTask.ID { + t.Fatalf("expected only admin task, got %+v", response) + } +} + +// TestAdminListRejectsInvalidSource 验证非法 source 直接报参数错误,不能静默忽略 +// 掉过滤条件返回全量列表。 +func TestAdminListRejectsInvalidSource(t *testing.T) { + db := openTaskDatabase(t) + service := newTaskService(db) + createTask(t, db, nil) + + if _, err := service.AdminList(context.Background(), AdminListRequest{Source: "not-a-real-source"}); err == nil { + t.Fatalf("expected invalid source error") + } else if code := taskErrorCode(t, err); code != "INVALID_REQUEST" { + t.Fatalf("expected INVALID_REQUEST, got %s", code) + } +} diff --git a/server/app/goauto/task/admin_service.go b/server/app/goauto/task/admin_service.go index 83246b0..78bf4db 100644 --- a/server/app/goauto/task/admin_service.go +++ b/server/app/goauto/task/admin_service.go @@ -32,6 +32,7 @@ type AdminListRequest struct { Page, PageSize int Status string GoodsID string + Source string DeviceID uint64 } @@ -189,6 +190,14 @@ func (service *Service) AdminList(ctx context.Context, request AdminListRequest) if request.GoodsID != "" { query = query.Where("collection_task.goods_id_snapshot LIKE ?", "%"+strings.TrimSpace(request.GoodsID)+"%") } + if request.Source != "" { + // `[必须]` 来源必须是三个合法值之一才下发过滤条件;非法值直接报错, + // 不能静默忽略——否则筛选框传错值会让采购员误以为看到的是全量列表。 + if request.Source != models.CollectionTaskSourceAdmin && request.Source != models.CollectionTaskSourceAgentCurrentPage && request.Source != models.CollectionTaskSourceImageSearch { + return AdminListResponse{}, serviceError("INVALID_REQUEST", "source 无效") + } + query = query.Where("collection_task.source = ?", request.Source) + } if request.DeviceID > 0 { query = query.Where("collection_task.device_id = ?", request.DeviceID) } diff --git a/server/app/goauto/task/cancel.go b/server/app/goauto/task/cancel.go index e6a4253..589bb92 100644 --- a/server/app/goauto/task/cancel.go +++ b/server/app/goauto/task/cancel.go @@ -25,6 +25,12 @@ type CancelResponse struct { type BatchCancelRequest struct { Source string `json:"source,omitempty"` Status string `json:"status,omitempty"` + // GoodsID 与列表页的同名筛选一致。 + // + // `[必须]` 取消范围必须与采购员眼前看到的列表一致。少了这一维,按 goods_id + // 筛出两条、按钮却取消三十几条——那正是当初放弃「两个固定按钮」、改用 + // 「筛选 + 一个按钮」想避免的事(#298)。 + GoodsID string `json:"goodsId,omitempty"` } type BatchCancelSkippedItem struct { @@ -125,6 +131,10 @@ func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequ if status != "" { query = query.Where("status = ?", status) } + // 与 AdminList 的 goodsId 过滤保持同一种匹配方式,否则两边范围会悄悄错开。 + if goodsID := strings.TrimSpace(request.GoodsID); goodsID != "" { + query = query.Where("goods_id_snapshot LIKE ?", "%"+goodsID+"%") + } var candidates []models.CollectionTask // 多取一条用于判断范围内是否还有未处理的任务,多出来的那条不参与取消。 if err := query.Order("id ASC").Limit(maxBatchCancelItems + 1).Find(&candidates).Error; err != nil { diff --git a/server/app/goauto/task/cancel_test.go b/server/app/goauto/task/cancel_test.go index abb495d..4190185 100644 --- a/server/app/goauto/task/cancel_test.go +++ b/server/app/goauto/task/cancel_test.go @@ -261,3 +261,38 @@ func TestBatchCancelWithinLimitDoesNotReportMore(t *testing.T) { t.Fatalf("cancelled=%d hasMore=%v, want 1 / false", response.CancelledCount, response.HasMore) } } + +// `[必须]` 取消范围必须与采购员眼前的列表一致。列表页的 Goods ID 搜索框若不参与 +// 取消范围,就会出现「筛出两条、按钮取消三十几条」——那正是当初放弃「两个固定 +// 按钮」、改用「筛选 + 一个按钮」想避免的事(#298)。 +func TestBatchCancelHonoursTheGoodsIDFilter(t *testing.T) { + db := openTaskDatabase(t) + service := newTaskService(db) + + target := createTask(t, db, nil) + other := createTask(t, db, nil) + + var targetRecord models.CollectionTask + if err := db.First(&targetRecord, target.ID).Error; err != nil { + t.Fatalf("load target: %v", err) + } + + response, err := service.BatchCancel(context.Background(), BatchCancelRequest{ + Status: models.TaskStatusPending, GoodsID: targetRecord.GoodsIDSnapshot, + }) + if err != nil { + t.Fatalf("batch cancel: %v", err) + } + if response.CancelledCount != 1 || len(response.CancelledIDs) != 1 || response.CancelledIDs[0] != target.ID { + t.Fatalf("只应取消命中 goods_id 的那一条,实际 %+v", response) + } + + // 范围外的任务必须原样不动。 + var stored models.CollectionTask + if err := db.First(&stored, other.ID).Error; err != nil { + t.Fatalf("load other: %v", err) + } + if stored.Status != models.TaskStatusPending { + t.Fatalf("goods_id 范围外的任务被取消了,status=%q", stored.Status) + } +} diff --git a/web/src/api/goauto/collection-tasks.js b/web/src/api/goauto/collection-tasks.js index b1e9d6e..b24eb31 100644 --- a/web/src/api/goauto/collection-tasks.js +++ b/web/src/api/goauto/collection-tasks.js @@ -7,3 +7,5 @@ export function batchCreateImageSearchCollectionTasks(data) { return request({ u export function getCollectionTask(taskId) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'get' }) } export function resetCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}/reset`, method: 'post', data }) } export function deleteCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'delete', data }) } +export function cancelCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}/cancel`, method: 'post', data }) } +export function batchCancelCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/batch-cancel', method: 'post', data }) } diff --git a/web/src/views/goauto/collection-tasks/index.vue b/web/src/views/goauto/collection-tasks/index.vue index b99841f..ca36ec9 100644 --- a/web/src/views/goauto/collection-tasks/index.vue +++ b/web/src/views/goauto/collection-tasks/index.vue @@ -9,12 +9,18 @@ - 查询重置 + + + 查询 + 重置 + 取消未开始的任务{{ pendingCancelCount }} + 第 {{ row.attemptNumber || 1 }} 次 + {{ sourceLabel(row.source) }} {{ row.deviceName || '空闲设备领取' }} {{ statusLabel(row.status) }} @@ -38,6 +44,21 @@ 取消创建 + + + + 共 {{ cancelBatch.previewTotal }} 个待执行任务将被取消{{ cancelBatch.previewTotal > cancelBatch.preview.length ? `(仅列出前 ${cancelBatch.preview.length} 个)` : '' }}: + + + {{ sourceLabel(row.source) }} + + + + 取消确认取消 + + @@ -72,7 +93,7 @@
共 {{ cancelBatch.previewTotal }} 个待执行任务将被取消{{ cancelBatch.previewTotal > cancelBatch.preview.length ? `(仅列出前 ${cancelBatch.preview.length} 个)` : '' }}: