From 0794dc580b467d936b3ed8803431fc2de1c8b444 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 25 Aug 2026 09:44:02 +0800 Subject: [PATCH] feat(#80): add SYB product process stages --- docs/03-business-rules-and-glossary.md | 14 +- server/app/goauto/purchase/batch.go | 46 ++++-- server/app/goauto/purchase/process_stage.go | 149 ++++++++++++++++++ .../app/goauto/purchase/process_stage_test.go | 85 ++++++++++ server/app/goauto/sybimport/handler.go | 2 +- server/app/goauto/sybimport/service.go | 54 ++++++- server/app/goauto/sybimport/service_test.go | 37 +++++ web/src/views/goauto/syb-products/index.vue | 13 +- web/tests/e2e/syb-product-layout.spec.ts | 9 +- 9 files changed, 385 insertions(+), 24 deletions(-) create mode 100644 server/app/goauto/purchase/process_stage.go create mode 100644 server/app/goauto/purchase/process_stage_test.go diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index e0230be..ab84eb9 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: 45bbde203b18cee7031aed8129f72a6e8f883033 -synchronized_at: 2026-08-24T09:26:57Z +wiki_revision: 73e38d6204acee7b6ea2c2eb9e9c918e807dbf3a +synchronized_at: 2026-08-25T01:39:35Z # 业务规则与术语 @@ -49,6 +49,16 @@ synchronized_at: 2026-08-24T09:26:57Z - Admin 商品列表的订单号筛选每行一个、去空去重后精确匹配,最多 100 个且单项最长 64 字符;店铺名称使用包含匹配。店铺、订单号与解析状态之间使用 AND,不再用该筛选框搜索蝦皮商品 ID 或商品标题。 - SYB 商品数据本身不保存 PDD 订单号、快递单号、支付或有效订单状态;Admin 的采购任务创建入口位于 SYB 商品列表,任务事实仍写入独立采购域。 + +### SYB 商品处理阶段 + +- SYB 商品页保留“解析状态”,另按 SYB、蝦皮、PDD、采集任务和采购任务的当前事实动态派生唯一“处理阶段”;不在 `syb_product` 保存容易过期的阶段字段。 +- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、颜色待匹配、可创建采购、已创建任务、采购成功、待人工核对。 +- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 颜色待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。 +- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。 +- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。 +- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。 + ## SYB 店铺过滤 - `syb_shop` 保存允许导入的 SYB 店铺;店铺名比较前统一去除首尾空白、转换全角/半角字符并忽略大小写。规范化后同名的店铺只能保留一条有效记录。 diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 3819b68..2b3b4ef 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -53,6 +53,10 @@ type BatchPreviewItem struct { Reason string `json:"reason,omitempty"` NextAction string `json:"nextAction,omitempty"` ActiveTaskID *uint64 `json:"activeTaskId,omitempty"` + ProcessStage string `json:"processStage,omitempty"` + ProcessStageLabel string `json:"processStageLabel,omitempty"` + ProcessStageReason string `json:"processStageReason,omitempty"` + ProcessNextAction string `json:"processNextAction,omitempty"` } type BatchPreviewResponse struct { @@ -89,6 +93,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba eligible := 0 for _, id := range ids { item := s.previewFromDataset(ctx, id, dataset, false) + item.applyProcessStage(processStageFromDataset(id, dataset, item)) if item.Eligible && deviceErr != nil { item.Eligible = false item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(deviceErr) @@ -102,21 +107,23 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba } type batchPreviewDataset struct { - sybByID map[uint64]models.SYBProduct - shopeeByID map[uint64]models.ShopeeProduct - pddByID map[uint64]models.PDDProduct - latestTaskBySYB map[uint64]models.PurchaseTask + sybByID map[uint64]models.SYBProduct + shopeeByID map[uint64]models.ShopeeProduct + pddByID map[uint64]models.PDDProduct + latestTaskBySYB map[uint64]models.PurchaseTask + latestCollectionByPDD map[uint64]models.CollectionTask } -// loadBatchPreviewDataset keeps the read-only preview bounded to four bulk -// queries regardless of the number of rows on the current page. Creation still -// revalidates each item against current data before persisting a task. +// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries. +// Pending PDD products add one collection-task query; active products retain +// the original four-query fast path. Creation still revalidates each item. func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (batchPreviewDataset, error) { dataset := batchPreviewDataset{ - sybByID: make(map[uint64]models.SYBProduct, len(ids)), - shopeeByID: make(map[uint64]models.ShopeeProduct), - pddByID: make(map[uint64]models.PDDProduct), - latestTaskBySYB: make(map[uint64]models.PurchaseTask), + sybByID: make(map[uint64]models.SYBProduct, len(ids)), + shopeeByID: make(map[uint64]models.ShopeeProduct), + pddByID: make(map[uint64]models.PDDProduct), + latestTaskBySYB: make(map[uint64]models.PurchaseTask), + latestCollectionByPDD: make(map[uint64]models.CollectionTask), } var sybProducts []models.SYBProduct if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil { @@ -149,6 +156,23 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba for _, pdd := range pddProducts { dataset.pddByID[pdd.ID] = pdd } + pendingPDDIDs := make([]uint64, 0) + for _, pdd := range pddProducts { + if pdd.Status == "pending" { + pendingPDDIDs = append(pendingPDDIDs, pdd.ID) + } + } + if len(pendingPDDIDs) > 0 { + var collectionTasks []models.CollectionTask + if err := s.DB.WithContext(ctx). + Where("pdd_product_id IN ? AND id = (SELECT MAX(ct2.id) FROM collection_task ct2 WHERE ct2.pdd_product_id = collection_task.pdd_product_id AND ct2.deleted_at IS NULL)", pendingPDDIDs). + Find(&collectionTasks).Error; err != nil { + return dataset, err + } + for _, task := range collectionTasks { + dataset.latestCollectionByPDD[task.PDDProductID] = task + } + } } } var tasks []models.PurchaseTask diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go new file mode 100644 index 0000000..e4ed9ea --- /dev/null +++ b/server/app/goauto/purchase/process_stage.go @@ -0,0 +1,149 @@ +package purchase + +import ( + "context" + "strings" + + "go-admin/app/goauto/models" +) + +const ( + ProcessStageManualAction = "manual_action" + ProcessStagePDDUnlinked = "pdd_unlinked" + ProcessStagePDDPending = "pdd_pending" + ProcessStagePDDCollecting = "pdd_collecting" + ProcessStagePDDCollectionFail = "pdd_collection_failed" + ProcessStageColorMapping = "color_mapping" + ProcessStagePurchaseReady = "purchase_ready" + ProcessStageTaskCreated = "task_created" + ProcessStagePurchaseSucceeded = "purchase_succeeded" + ProcessStageOrderReview = "order_review" +) + +var processStageLabels = map[string]string{ + ProcessStageManualAction: "待人工处理", + ProcessStagePDDUnlinked: "未关联 PDD", + ProcessStagePDDPending: "PDD 待采集", + ProcessStagePDDCollecting: "PDD 采集中", + ProcessStagePDDCollectionFail: "PDD 采集失败", + ProcessStageColorMapping: "颜色待匹配", + ProcessStagePurchaseReady: "可创建采购", + ProcessStageTaskCreated: "已创建任务", + ProcessStagePurchaseSucceeded: "采购成功", + ProcessStageOrderReview: "待人工核对", +} + +type ProcessStageResult struct { + Stage string + Label string + Reason string + NextAction string + ActiveTaskID *uint64 +} + +func ValidProcessStage(stage string) bool { + if strings.TrimSpace(stage) == "" { + return true + } + _, ok := processStageLabels[stage] + return ok +} + +// ProcessStages derives one user-facing workflow stage per SYB product. It is +// deliberately read-only and never calls the AI matcher. +func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]ProcessStageResult, error) { + result := make(map[uint64]ProcessStageResult, len(ids)) + if len(ids) == 0 { + return result, nil + } + dataset, err := s.loadBatchPreviewDataset(ctx, ids) + if err != nil { + return nil, err + } + for _, id := range ids { + preview := s.previewFromDataset(ctx, id, dataset, false) + result[id] = processStageFromDataset(id, dataset, preview) + } + return result, nil +} + +func (item *BatchPreviewItem) applyProcessStage(stage ProcessStageResult) { + item.ProcessStage = stage.Stage + item.ProcessStageLabel = stage.Label + item.ProcessStageReason = stage.Reason + item.ProcessNextAction = stage.NextAction + if stage.ActiveTaskID != nil { + item.ActiveTaskID = stage.ActiveTaskID + } +} + +func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview BatchPreviewItem) ProcessStageResult { + stage := func(value, reason, next string) ProcessStageResult { + return ProcessStageResult{Stage: value, Label: processStageLabels[value], Reason: reason, NextAction: next} + } + if task, ok := dataset.latestTaskBySYB[id]; ok { + taskID := task.ID + switch task.Status { + case models.PurchaseTaskStatusOrderResultUnknown: + out := stage(ProcessStageOrderReview, "订单结果不明确,请先人工核对,禁止重试", "open_task") + out.ActiveTaskID = &taskID + return out + case models.PurchaseTaskStatusOrderCreated: + out := stage(ProcessStagePurchaseSucceeded, "已取得订单号和下单时间,不代表已支付", "open_task") + out.ActiveTaskID = &taskID + return out + case models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending, + models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted: + out := stage(ProcessStageTaskCreated, "已有未结束的采购任务", "open_task") + out.ActiveTaskID = &taskID + return out + } + } + + syb, ok := dataset.sybByID[id] + if !ok { + return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh") + } + if syb.ParseStatus == models.SYBParseStatusFailed { + return stage(ProcessStageManualAction, "解析失败,请先处理", "reparse") + } + if syb.ShopeeProductID == nil { + return stage(ProcessStageManualAction, "尚未关联蝦皮商品", "refresh") + } + shopee, ok := dataset.shopeeByID[*syb.ShopeeProductID] + if !ok { + return stage(ProcessStageManualAction, "关联的蝦皮商品不存在", "open_shopee") + } + if shopee.PDDProductID == nil { + return stage(ProcessStagePDDUnlinked, "请先关联拼多多商品", "open_mapping") + } + pdd, ok := dataset.pddByID[*shopee.PDDProductID] + if !ok { + return stage(ProcessStagePDDUnlinked, "关联的拼多多商品不存在", "open_mapping") + } + if pdd.Status == "disabled" { + return stage(ProcessStageManualAction, "拼多多商品已停用", "open_pdd") + } + if pdd.Status == "pending" { + if task, found := dataset.latestCollectionByPDD[pdd.ID]; found { + switch task.Status { + case models.TaskStatusPending, models.TaskStatusRunning: + return stage(ProcessStagePDDCollecting, "商品采集任务正在执行", "open_pdd") + case models.TaskStatusFailed: + return stage(ProcessStagePDDCollectionFail, "最近一次商品采集失败", "open_pdd") + } + } + return stage(ProcessStagePDDPending, "拼多多商品尚未采集完成", "open_pdd") + } + if preview.ReasonCode == CodeMappingRequired { + return stage(ProcessStageColorMapping, "颜色匹配无效,请重新选择", "open_mapping") + } + if preview.Eligible { + return stage(ProcessStagePurchaseReady, "当前条件可以创建采购任务", "") + } + reason := strings.TrimSpace(preview.Reason) + if reason == "" { + reason = "请检查商品和规格数据" + } + return stage(ProcessStageManualAction, reason, preview.NextAction) +} diff --git a/server/app/goauto/purchase/process_stage_test.go b/server/app/goauto/purchase/process_stage_test.go new file mode 100644 index 0000000..0d4e989 --- /dev/null +++ b/server/app/goauto/purchase/process_stage_test.go @@ -0,0 +1,85 @@ +package purchase + +import ( + "testing" + + "go-admin/app/goauto/models" +) + +func TestProcessStageDerivesAllUserFacingStages(t *testing.T) { + base := func() batchPreviewDataset { + pddID := uint64(3) + shopeeID := uint64(2) + return batchPreviewDataset{ + sybByID: map[uint64]models.SYBProduct{1: {ID: 1, ParseStatus: models.SYBParseStatusSuccess, ShopeeProductID: &shopeeID}}, + shopeeByID: map[uint64]models.ShopeeProduct{2: {ID: 2, PDDProductID: &pddID}}, + pddByID: map[uint64]models.PDDProduct{3: {ID: 3, Status: "active"}}, + latestTaskBySYB: make(map[uint64]models.PurchaseTask), + latestCollectionByPDD: make(map[uint64]models.CollectionTask), + } + } + tests := []struct { + name string + want string + set func(*batchPreviewDataset, *BatchPreviewItem) + }{ + {"待人工处理", ProcessStageManualAction, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + row := d.sybByID[1] + row.ParseStatus = models.SYBParseStatusFailed + d.sybByID[1] = row + }}, + {"未关联 PDD", ProcessStagePDDUnlinked, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + row := d.shopeeByID[2] + row.PDDProductID = nil + d.shopeeByID[2] = row + }}, + {"PDD 待采集", ProcessStagePDDPending, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + row := d.pddByID[3] + row.Status = "pending" + d.pddByID[3] = row + }}, + {"PDD 采集中", ProcessStagePDDCollecting, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + row := d.pddByID[3] + row.Status = "pending" + d.pddByID[3] = row + d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: 3, Status: models.TaskStatusRunning} + }}, + {"PDD 采集失败", ProcessStagePDDCollectionFail, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + row := d.pddByID[3] + row.Status = "pending" + d.pddByID[3] = row + d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: 3, Status: models.TaskStatusFailed} + }}, + {"颜色待匹配", ProcessStageColorMapping, func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.ReasonCode = CodeMappingRequired }}, + {"可创建采购", ProcessStagePurchaseReady, func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.Eligible = true }}, + {"已创建任务", ProcessStageTaskCreated, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + sybID := uint64(1) + d.latestTaskBySYB[1] = models.PurchaseTask{ID: 11, SYBProductID: &sybID, Status: models.PurchaseTaskStatusRunning} + }}, + {"采购成功", ProcessStagePurchaseSucceeded, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + sybID := uint64(1) + d.latestTaskBySYB[1] = models.PurchaseTask{ID: 12, SYBProductID: &sybID, Status: models.PurchaseTaskStatusOrderCreated} + }}, + {"待人工核对", ProcessStageOrderReview, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + sybID := uint64(1) + d.latestTaskBySYB[1] = models.PurchaseTask{ID: 13, SYBProductID: &sybID, Status: models.PurchaseTaskStatusOrderResultUnknown} + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dataset := base() + preview := BatchPreviewItem{SYBProductID: 1} + tt.set(&dataset, &preview) + got := processStageFromDataset(1, dataset, preview) + if got.Stage != tt.want || got.Label != tt.name { + t.Fatalf("got stage=%q label=%q, want %q/%q", got.Stage, got.Label, tt.want, tt.name) + } + }) + } +} + +func TestValidProcessStageRejectsInternalOrUnknownValues(t *testing.T) { + if !ValidProcessStage("") || !ValidProcessStage(ProcessStagePurchaseReady) || ValidProcessStage("running") || ValidProcessStage("unknown") { + t.Fatal("process stage validation accepted an internal or unknown value") + } +} diff --git a/server/app/goauto/sybimport/handler.go b/server/app/goauto/sybimport/handler.go index e1cb72e..7d0b823 100644 --- a/server/app/goauto/sybimport/handler.go +++ b/server/app/goauto/sybimport/handler.go @@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) { return } response, err := service.List(c.Request.Context(), ListRequest{ - Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), + Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), }) if err != nil { writeError(c, err) diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go index c5c543f..2c9a1ff 100644 --- a/server/app/goauto/sybimport/service.go +++ b/server/app/goauto/sybimport/service.go @@ -7,6 +7,7 @@ import ( "strings" "go-admin/app/goauto/models" + "go-admin/app/goauto/purchase" "gorm.io/gorm" ) @@ -42,6 +43,7 @@ type ListRequest struct { ShopName string OrderCodes []string ParseStatus string + ProcessStage string } type ListResponse struct { @@ -51,10 +53,11 @@ type ListResponse struct { PageSize int `json:"pageSize"` } -// List does not accept a "linked to purchase task" filter and never will: -// this table has no purchase field to filter on (#41: 不做什么). The -// "采购状态" column the prototype reserves for #35/#44 is injected by that -// unit's own query, not by this endpoint. +const processStageBatchSize = 500 + +// List keeps workflow stages derived from the current related facts instead of +// persisting them on syb_product. With a processStage filter, derivation must +// happen before pagination so totals and later pages stay accurate. func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) { if request.Page < 1 { request.Page = 1 @@ -85,6 +88,49 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp } query = query.Where("parse_status = ?", request.ParseStatus) } + request.ProcessStage = strings.TrimSpace(request.ProcessStage) + if !purchase.ValidProcessStage(request.ProcessStage) { + return ListResponse{}, invalidRequest("processStage 无效") + } + if request.ProcessStage != "" { + var candidates []models.SYBProduct + if err := query.Order("updated_at DESC, id DESC").Find(&candidates).Error; err != nil { + return ListResponse{}, internalError(err) + } + matched := make([]models.SYBProduct, 0) + stageService := purchase.NewService(service.DB) + for start := 0; start < len(candidates); start += processStageBatchSize { + end := start + processStageBatchSize + if end > len(candidates) { + end = len(candidates) + } + ids := make([]uint64, 0, end-start) + for _, item := range candidates[start:end] { + ids = append(ids, item.ID) + } + stages, err := stageService.ProcessStages(ctx, ids) + if err != nil { + return ListResponse{}, internalError(err) + } + for _, item := range candidates[start:end] { + if stages[item.ID].Stage == request.ProcessStage { + matched = append(matched, item) + } + } + } + total := int64(len(matched)) + start := (request.Page - 1) * request.PageSize + if start >= len(matched) { + matched = nil + } else { + end := start + request.PageSize + if end > len(matched) { + end = len(matched) + } + matched = matched[start:end] + } + return ListResponse{Items: matched, Total: total, Page: request.Page, PageSize: request.PageSize}, nil + } var total int64 if err := query.Count(&total).Error; err != nil { return ListResponse{}, internalError(err) diff --git a/server/app/goauto/sybimport/service_test.go b/server/app/goauto/sybimport/service_test.go index 87d3974..2eede97 100644 --- a/server/app/goauto/sybimport/service_test.go +++ b/server/app/goauto/sybimport/service_test.go @@ -102,6 +102,43 @@ func TestServiceListRejectsInvalidParseStatus(t *testing.T) { } } +func TestServiceListFiltersProcessStageBeforePagination(t *testing.T) { + db := openTestDB(t) + order := realOrder() + for i := 0; i < 3; i++ { + rowOrder := order + rowOrder.Code = fmt.Sprintf("STAGE-%d", i) + rowOrder.StockID += uint64(i) + detail := realDetailA() + detail.ID += uint64(i) + if _, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail); err != nil { + t.Fatal(err) + } + } + var failed models.SYBProduct + if err := db.Where("order_code = ?", "STAGE-2").First(&failed).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&failed).Updates(map[string]any{"parse_status": models.SYBParseStatusFailed, "target_color": "", "target_size": ""}).Error; err != nil { + t.Fatal(err) + } + service := sybimport.NewService(db) + page, err := service.List(context.Background(), sybimport.ListRequest{Page: 2, PageSize: 1, ProcessStage: "pdd_unlinked"}) + if err != nil { + t.Fatal(err) + } + if page.Total != 2 || len(page.Items) != 1 { + t.Fatalf("stage filtering must happen before pagination: %+v", page) + } + combined, err := service.List(context.Background(), sybimport.ListRequest{ParseStatus: models.SYBParseStatusFailed, ProcessStage: "manual_action"}) + if err != nil || combined.Total != 1 || combined.Items[0].OrderCode != "STAGE-2" { + t.Fatalf("parse and process filters did not combine: response=%+v err=%v", combined, err) + } + if _, err := service.List(context.Background(), sybimport.ListRequest{ProcessStage: "running"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest { + t.Fatalf("invalid process stage was accepted: %v", err) + } +} + func TestServiceDetailNotFound(t *testing.T) { db := openTestDB(t) service := sybimport.NewService(db) diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 6d6de5e..c6568b4 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -11,9 +11,10 @@ + 查询重置创建采购 - + @@ -24,7 +25,7 @@ - + @@ -134,13 +135,14 @@ export default { shopOptions: [], shopOptionsLoaded: false, shopOptionsPromise: null, purchaseDialog: { open: false, loading: false, saving: false, ids: [], deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 }, purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 }, - query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '' }, + query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }, detail: { open: false, loading: false, item: null }, correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' } } }, computed: { canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') }, + processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '颜色待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] }, firstCreatedTaskId() { return this.purchaseResult.items.find(item => item.taskId)?.taskId || null }, formattedRaw() { if (!this.detail.item) return '' @@ -158,7 +160,7 @@ export default { this.$refs.productTable?.clearSelection() const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {} try { - const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus }, requestOptions) + const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage }, requestOptions) if (generation !== this.loadGeneration) return this.products = r.data.items this.total = r.data.total @@ -177,7 +179,7 @@ export default { } }, search() { this.query.page = 1; this.load() }, - reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '' }; this.load() }, + reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() }, normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() }, async ensureShopOptions() { if (this.shopOptionsLoaded) return @@ -204,6 +206,7 @@ export default { rowSelectable(row) { return this.canPurchase && this.purchaseReady(row).eligible }, purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } }, purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` }, + processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '颜色待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } }, purchaseActionLabel(item) { return { open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' }, async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration) { if (generation !== this.loadGeneration) return diff --git a/web/tests/e2e/syb-product-layout.spec.ts b/web/tests/e2e/syb-product-layout.spec.ts index ef4bec9..03e503d 100644 --- a/web/tests/e2e/syb-product-layout.spec.ts +++ b/web/tests/e2e/syb-product-layout.spec.ts @@ -10,7 +10,7 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}}) if (url.pathname.endsWith('/api/admin/v1/syb-shops')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 1, displayName: '大碼臻選', enabled: true }, { id: 2, displayName: '歷史店鋪', enabled: false }], total: 2, enabledCount: 1, page: 1, pageSize: 200 }}}) if (url.pathname.endsWith('/api/admin/v1/syb-products')) { listURLs.push(url); return route.fulfill({ json: { code: 200, data: { items: [{ id: 1, orderCode: 'SYB001', shopeeItemId: '26154802794', shopeeProductId: 9, productTitle: '用于验证加宽后的虾皮商品标题区域', shopName: '大碼臻選', targetColor: '黑色', targetSize: 'XL', quantity: 2, unitPriceCent: 1000, parseStatus: 'success', manuallyConfirmed: false }, { id: 2, orderCode: 'SYB002', shopeeItemId: '26154802795', productTitle: '尚未关联的商品', shopName: '大碼臻選', targetColor: '白色', targetSize: 'L', quantity: 1, unitPriceCent: 900, parseStatus: 'success', manuallyConfirmed: false }], total: 2, page: 1, pageSize: 20 }}}) } - if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 1, eligible: true, minUnitPriceCent: 200, maxUnitPriceCent: 1500 }, { sybProductId: 2, eligible: false, reason: '尚未关联蝦皮商品' }], eligibleCount: 1, skippedCount: 1 }}}) + if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 1, eligible: true, minUnitPriceCent: 200, maxUnitPriceCent: 1500, processStage: 'purchase_ready', processStageLabel: '可创建采购', processStageReason: '当前条件可以创建采购任务' }, { sybProductId: 2, eligible: false, reason: '尚未关联蝦皮商品', processStage: 'manual_action', processStageLabel: '待人工处理', processStageReason: '尚未关联蝦皮商品' }], eligibleCount: 1, skippedCount: 1 }}}) return route.fulfill({ json: { code: 200, data: [] }}) }) @@ -24,6 +24,8 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context await expect(page.getByRole('columnheader', { name: '蝦皮关联' })).toHaveCount(0) await expect(page.getByText('本页不展示采购任务、PDD 订单号、快递单号和支付状态。', { exact: true })).toHaveCount(0) await expect(page.getByText('未关联', { exact: true }).last()).toBeVisible() + await expect(page.locator('tbody').getByText('可创建采购', { exact: true })).toBeVisible() + await expect(page.locator('tbody').getByText('待人工处理', { exact: true })).toBeVisible() await expect(page.getByText('26154802795 ↗', { exact: true })).toHaveCount(0) const shopeeTitle = page.locator('.shopee-title').first() await expect(shopeeTitle).toHaveAttribute('title', '用于验证加宽后的虾皮商品标题区域') @@ -65,6 +67,11 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context const latest = listURLs.at(-1) expect(latest?.searchParams.get('shopName')).toBe('大碼') expect(latest?.searchParams.get('orderCodes')).toBe('SYB001,SYB002') + + await page.locator('.el-form-item').filter({ hasText: '处理阶段' }).locator('.el-select').click() + await page.getByRole('option', { name: 'PDD 待采集' }).click() + await page.getByRole('button', { name: '查询', exact: true }).click() + await expect.poll(() => listURLs.at(-1)?.searchParams.get('processStage')).toBe('pdd_pending') }) test('店铺选项加载失败时仍可输入历史店铺查询', async({ page, context }) => {