diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 2b3b4ef..c5bea17 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -315,13 +315,13 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat return item } if shopee.PDDProductID == nil { - item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联拼多多商品,请先去匹配", "open_mapping" + item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink return item } item.PDDProductID = shopee.PDDProductID pdd, found := dataset.pddByID[*shopee.PDDProductID] if !found { - item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_FOUND", "关联的拼多多商品不存在,请先重新匹配", "open_mapping" + item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_FOUND", "关联的 PDD 商品不存在,请重新关联", processActionOpenPDDLink return item } item.PDDGoodsID, item.PDDTitle = pdd.GoodsID, pdd.Title diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index 15c2a3e..9dced0c 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -43,6 +43,23 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) { } } +func TestBatchPreviewUsesLinkActionBeforePDDMapping(t *testing.T) { + db := testDB(t) + fixture := seed(t, db, liveCaps(), true) + if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("pdd_product_id", nil).Error; err != nil { + t.Fatal(err) + } + + response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}}) + if err != nil { + t.Fatal(err) + } + item := response.Items[0] + if item.Eligible || item.ReasonCode != "PDD_NOT_LINKED" || item.NextAction != processActionOpenPDDLink || item.ProcessNextAction != processActionOpenPDDLink { + t.Fatalf("PDD link action missing: %+v", item) + } +} + func TestBatchCreateKeepsPartialSuccessAndReplaysCreatedItems(t *testing.T) { db := testDB(t) fixture := seed(t, db, liveCaps(), true) diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index e4ed9ea..2a3cd16 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -18,6 +18,7 @@ const ( ProcessStageTaskCreated = "task_created" ProcessStagePurchaseSucceeded = "purchase_succeeded" ProcessStageOrderReview = "order_review" + processActionOpenPDDLink = "open_pdd_link" ) var processStageLabels = map[string]string{ @@ -115,11 +116,11 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat return stage(ProcessStageManualAction, "关联的蝦皮商品不存在", "open_shopee") } if shopee.PDDProductID == nil { - return stage(ProcessStagePDDUnlinked, "请先关联拼多多商品", "open_mapping") + return stage(ProcessStagePDDUnlinked, "请先关联 PDD 商品", processActionOpenPDDLink) } pdd, ok := dataset.pddByID[*shopee.PDDProductID] if !ok { - return stage(ProcessStagePDDUnlinked, "关联的拼多多商品不存在", "open_mapping") + return stage(ProcessStagePDDUnlinked, "关联的 PDD 商品不存在,请重新关联", processActionOpenPDDLink) } if pdd.Status == "disabled" { return stage(ProcessStageManualAction, "拼多多商品已停用", "open_pdd") diff --git a/server/app/goauto/purchase/process_stage_test.go b/server/app/goauto/purchase/process_stage_test.go index 0d4e989..2376cfd 100644 --- a/server/app/goauto/purchase/process_stage_test.go +++ b/server/app/goauto/purchase/process_stage_test.go @@ -19,48 +19,49 @@ func TestProcessStageDerivesAllUserFacingStages(t *testing.T) { } } tests := []struct { - name string - want string - set func(*batchPreviewDataset, *BatchPreviewItem) + name string + want string + wantAction string + set func(*batchPreviewDataset, *BatchPreviewItem) }{ - {"待人工处理", ProcessStageManualAction, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + {"待人工处理", ProcessStageManualAction, "reparse", func(d *batchPreviewDataset, _ *BatchPreviewItem) { row := d.sybByID[1] row.ParseStatus = models.SYBParseStatusFailed d.sybByID[1] = row }}, - {"未关联 PDD", ProcessStagePDDUnlinked, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + {"未关联 PDD", ProcessStagePDDUnlinked, processActionOpenPDDLink, func(d *batchPreviewDataset, _ *BatchPreviewItem) { row := d.shopeeByID[2] row.PDDProductID = nil d.shopeeByID[2] = row }}, - {"PDD 待采集", ProcessStagePDDPending, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + {"PDD 待采集", ProcessStagePDDPending, "open_pdd", func(d *batchPreviewDataset, _ *BatchPreviewItem) { row := d.pddByID[3] row.Status = "pending" d.pddByID[3] = row }}, - {"PDD 采集中", ProcessStagePDDCollecting, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + {"PDD 采集中", ProcessStagePDDCollecting, "open_pdd", 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) { + {"PDD 采集失败", ProcessStagePDDCollectionFail, "open_pdd", 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) { + {"颜色待匹配", ProcessStageColorMapping, "open_mapping", func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.ReasonCode = CodeMappingRequired }}, + {"可创建采购", ProcessStagePurchaseReady, "", func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.Eligible = true }}, + {"已创建任务", ProcessStageTaskCreated, "open_task", func(d *batchPreviewDataset, _ *BatchPreviewItem) { sybID := uint64(1) d.latestTaskBySYB[1] = models.PurchaseTask{ID: 11, SYBProductID: &sybID, Status: models.PurchaseTaskStatusRunning} }}, - {"采购成功", ProcessStagePurchaseSucceeded, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + {"采购成功", ProcessStagePurchaseSucceeded, "open_task", func(d *batchPreviewDataset, _ *BatchPreviewItem) { sybID := uint64(1) d.latestTaskBySYB[1] = models.PurchaseTask{ID: 12, SYBProductID: &sybID, Status: models.PurchaseTaskStatusOrderCreated} }}, - {"待人工核对", ProcessStageOrderReview, func(d *batchPreviewDataset, _ *BatchPreviewItem) { + {"待人工核对", ProcessStageOrderReview, "open_task", func(d *batchPreviewDataset, _ *BatchPreviewItem) { sybID := uint64(1) d.latestTaskBySYB[1] = models.PurchaseTask{ID: 13, SYBProductID: &sybID, Status: models.PurchaseTaskStatusOrderResultUnknown} }}, @@ -71,8 +72,8 @@ func TestProcessStageDerivesAllUserFacingStages(t *testing.T) { 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) + if got.Stage != tt.want || got.Label != tt.name || got.NextAction != tt.wantAction { + t.Fatalf("got stage=%q label=%q action=%q, want %q/%q/%q", got.Stage, got.Label, got.NextAction, tt.want, tt.name, tt.wantAction) } }) } @@ -83,3 +84,20 @@ func TestValidProcessStageRejectsInternalOrUnknownValues(t *testing.T) { t.Fatal("process stage validation accepted an internal or unknown value") } } + +func TestProcessStageMissingPDDUsesRelinkAction(t *testing.T) { + pddID := uint64(3) + shopeeID := uint64(2) + dataset := 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{}, + latestTaskBySYB: make(map[uint64]models.PurchaseTask), + latestCollectionByPDD: make(map[uint64]models.CollectionTask), + } + + got := processStageFromDataset(1, dataset, BatchPreviewItem{SYBProductID: 1}) + if got.Stage != ProcessStagePDDUnlinked || got.NextAction != processActionOpenPDDLink || got.Reason != "关联的 PDD 商品不存在,请重新关联" { + t.Fatalf("missing PDD did not request relink: %+v", got) + } +} diff --git a/web/src/views/goauto/shopee-products/index.vue b/web/src/views/goauto/shopee-products/index.vue index ff0354c..da4728f 100644 --- a/web/src/views/goauto/shopee-products/index.vue +++ b/web/src/views/goauto/shopee-products/index.vue @@ -183,7 +183,7 @@ export default { created() { this.load() const productId = Number(this.$route.query.productId) - if (Number.isInteger(productId) && productId > 0) this.openDetail(productId) + if (Number.isInteger(productId) && productId > 0) this.openRouteDetail(productId).catch(() => {}) }, methods: { emptyCreate() { return { shopeeItemId: '', title: '', shopName: '', pddProductId: null } }, @@ -281,6 +281,13 @@ export default { }, // ---------------- 详情 / 映射 ---------------- + async openRouteDetail(id) { + try { + await this.openDetail(id) + } finally { + if (String(this.$route.query.action || '') === 'link_pdd' && this.detail.product?.id === id) this.openPddPicker('link') + } + }, async openDetail(id) { this.detail = { ...this.emptyDetail(), open: true, loading: true, highlightColor: String(this.$route.query.targetColor || '').trim() } try { diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index d5e9866..f2c9eea 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -207,7 +207,7 @@ export default { 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] || '' }, + purchaseActionLabel(item) { return { open_pdd_link: '去关联', 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 this.purchaseReadiness = {} @@ -226,10 +226,11 @@ export default { }, priceText(row) { if (row.unitPriceCent === null || row.unitPriceCent === undefined) return '—'; return `¥${(row.unitPriceCent / 100).toFixed(2)}` }, parseMeta(status) { return { success: { label: '成功', type: 'success' }, uncertain: { label: '存疑', type: 'warning' }, failed: { label: '失败', type: 'danger' }}[status] || { label: status || '-', type: 'info' } }, - openShopeeDetail(shopeeProductId, targetColor = '') { + openShopeeDetail(shopeeProductId, targetColor = '', action = '') { if (!shopeeProductId) { ElMessage.warning('该明细尚未关联虾皮商品'); return } const query = { productId: shopeeProductId } if (targetColor) query.targetColor = targetColor + if (action) query.action = action const route = this.$router.resolve({ path: '/shopee-products/index', query }) window.open(route.href, '_blank', 'noopener') }, @@ -240,6 +241,7 @@ export default { }, openPurchaseTask(taskId) { this.$router.push({ path: '/purchase-tasks/index', query: { taskId }}) }, runPurchaseNextAction(row, readiness) { + if (readiness.nextAction === 'open_pdd_link') return this.openShopeeDetail(row.shopeeProductId, '', 'link_pdd') if (readiness.nextAction === 'open_mapping' || readiness.nextAction === 'open_shopee') return this.openShopeeDetail(row.shopeeProductId, row.targetColor) if (readiness.nextAction === 'open_pdd') return this.openPddDetail(readiness.pddProductId) if (readiness.nextAction === 'open_task' && readiness.activeTaskId) return this.openPurchaseTask(readiness.activeTaskId) diff --git a/web/tests/e2e/syb-product-layout.spec.ts b/web/tests/e2e/syb-product-layout.spec.ts index 0c47b9a..7c611db 100644 --- a/web/tests/e2e/syb-product-layout.spec.ts +++ b/web/tests/e2e/syb-product-layout.spec.ts @@ -115,6 +115,31 @@ test('PDD 待采集的下一步打开对应 PDD 商品而不是蝦皮商品', as expect(popup.url()).not.toContain('/shopee-products/index') }) +test('未关联 PDD 显示去关联并直接打开 PDD 商品选择器', async({ page, context }) => { + await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) + await context.route('**/api/**', async route => { + const url = new URL(route.request().url()) + if (url.pathname.startsWith('/src/api/')) return route.continue() + 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-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 41, orderCode: 'SYB-LINK-41', shopeeItemId: '26154802794', shopeeProductId: 9, productTitle: '未关联商品', shopName: '大碼臻選', targetColor: '黑色', targetSize: 'XL', quantity: 1, unitPriceCent: 1000, parseStatus: 'success' }, { id: 42, orderCode: 'SYB-MAP-42', shopeeItemId: '26154802795', shopeeProductId: 10, productTitle: '待匹配商品', shopName: '大碼臻選', targetColor: '白色', targetSize: 'L', quantity: 1, unitPriceCent: 1200, parseStatus: 'success' }], 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: 41, eligible: false, processStage: 'pdd_unlinked', processStageLabel: '未关联 PDD', processStageReason: '请先关联 PDD 商品', processNextAction: 'open_pdd_link' }, { sybProductId: 42, eligible: false, processStage: 'color_mapping', processStageLabel: '颜色待匹配', processStageReason: '颜色匹配无效,请重新选择', processNextAction: 'open_mapping' }], eligibleCount: 0, skippedCount: 2 }}}) + if (url.pathname.endsWith('/api/admin/v1/shopee-products/9')) return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: '26154802794', title: '未关联商品', shopName: '大碼臻選', pddProductId: null, specs: [] } } }}) + if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 }}}) + return route.fulfill({ json: { code: 200, data: [] }}) + }) + + await page.goto('http://localhost:9527/#/syb-products/index') + await expect(page.getByText('请先关联 PDD 商品', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '去关联', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '去匹配', exact: true })).toBeVisible() + + const popupPromise = page.waitForEvent('popup') + await page.getByRole('button', { name: '去关联', exact: true }).click() + const popup = await popupPromise + await expect.poll(() => popup.url()).toContain('/#/shopee-products/index?productId=9&action=link_pdd') + await expect(popup.getByText('搜索并选择 PDD 商品', { exact: true })).toBeVisible() +}) + test('SYB 商品页不读取或展示后台同步状态', async({ page, context }) => { await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) let syncRunCalls = 0