diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 6eeddaa..22bb1cf 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: a49579d398b21029aa23cb42201b54a3bed92643 -synchronized_at: 2026-08-31T16:06:48Z +wiki_revision: 51de401da3555517a5e560ca72e064fb11bd8d89 +synchronized_at: 2026-09-01T00:58:04Z # 业务规则与术语 @@ -136,6 +136,7 @@ synchronized_at: 2026-08-31T16:06:48Z - 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。 - Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。 - SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取、已确认映射和本地确定性匹配,禁止调用 AI Provider。正式创建仍按最新数据完整复核,只有创建阶段无法唯一确定规格时才允许调用 AI。 +- SYB 商品页的 PDD 采集资格独立于采购处理阶段:只要已关联的 PDD 商品未停用、没有 `pending` / `running` 采集任务且存在可用采集规则,即可创建采集任务;因此已完成采集并进入“可创建采购”的商品也可以重新采集。相同 PDD 商品在批量创建前按商品去重,服务端创建时仍按最新状态复核。 - 批量创建读取管理员选定的当前采购规则并重新执行契约校验,每条任务保存不可变快照;当前规则缺失或无效时明确阻断,不回退到代码常量。设备默认人工指定,也可以留空由符合能力的空闲设备领取。 - 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:当前采购规则可用 `priceGuard.minRatio`(0.1~1.0)和 `maxRatio`(1.0~3.0)配置比例,缺省仍为 0.2 / 1.5;最低价向下取整、最高价向上取整到人民币分。已确认颜色映射时按该颜色计算,待规格探测时以全部可用颜色的最低/最高价计算,参考价取最高价;没有可用颜色价格时不能创建。 - 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 0d3c399..c1a0c56 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: b074c3ec1b7fc605af3cfa982b60a8e6b6ff3664 -synchronized_at: 2026-08-31T16:07:32Z +wiki_revision: 94a99754058be22ec1fbb4e2e661b91a23fc026d +synchronized_at: 2026-09-01T00:58:35Z # MVP 共享 API 契约 @@ -501,7 +501,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 | | `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 | | `POST` | `/api/admin/v1/purchase-tasks/stock` | 创建备货采购;`requestId`、`executionMode`、`pddProductId`、可选 `deviceId` / `pddAccountId`、`color`、可选 `size`、`quantity`、`minUnitPriceCent`、`maxUnitPriceCent`。服务端使用内置规则并从所选颜色归档派生参考价;相同 `requestId` 幂等返回原任务 | -| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回是否可创建、价格区间、原因和下一步,不创建任务 | +| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` 与可选禁用原因 `collectionDisabledReason`,不创建任务 | | `POST` | `/api/admin/v1/purchase-tasks/batch` | 批次 `requestId`、`sybProductIds`(1~100)和可选 `deviceId`;每条派生稳定幂等键并独立创建,部分失败不回滚成功项 | | `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 | | `POST` | `/api/admin/v1/purchase-tasks/{taskId}/payment-review` | `paid` 或 `unpaid`,管理员和采购员可操作 | diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 735a410..8cc1cb4 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -32,32 +32,34 @@ type BatchCreateRequest struct { } type BatchPreviewItem struct { - SYBProductID uint64 `json:"sybProductId"` - OrderCode string `json:"orderCode,omitempty"` - ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"` - ShopeeItemID string `json:"shopeeItemId,omitempty"` - ProductTitle string `json:"productTitle,omitempty"` - PDDProductID *uint64 `json:"pddProductId,omitempty"` - PDDGoodsID string `json:"pddGoodsId,omitempty"` - PDDTitle string `json:"pddTitle,omitempty"` - TargetColor string `json:"targetColor,omitempty"` - TargetSize string `json:"targetSize,omitempty"` - MappedColor string `json:"mappedColor,omitempty"` - MappedSize string `json:"mappedSize,omitempty"` - Quantity int64 `json:"quantity,omitempty"` - ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent,omitempty"` - MinUnitPriceCent int64 `json:"minUnitPriceCent,omitempty"` - MaxUnitPriceCent int64 `json:"maxUnitPriceCent,omitempty"` - Currency string `json:"currency,omitempty"` - Eligible bool `json:"eligible"` - ReasonCode string `json:"reasonCode,omitempty"` - 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"` + SYBProductID uint64 `json:"sybProductId"` + OrderCode string `json:"orderCode,omitempty"` + ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"` + ShopeeItemID string `json:"shopeeItemId,omitempty"` + ProductTitle string `json:"productTitle,omitempty"` + PDDProductID *uint64 `json:"pddProductId,omitempty"` + PDDGoodsID string `json:"pddGoodsId,omitempty"` + PDDTitle string `json:"pddTitle,omitempty"` + TargetColor string `json:"targetColor,omitempty"` + TargetSize string `json:"targetSize,omitempty"` + MappedColor string `json:"mappedColor,omitempty"` + MappedSize string `json:"mappedSize,omitempty"` + Quantity int64 `json:"quantity,omitempty"` + ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent,omitempty"` + MinUnitPriceCent int64 `json:"minUnitPriceCent,omitempty"` + MaxUnitPriceCent int64 `json:"maxUnitPriceCent,omitempty"` + Currency string `json:"currency,omitempty"` + Eligible bool `json:"eligible"` + ReasonCode string `json:"reasonCode,omitempty"` + 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"` + CollectionEligible bool `json:"collectionEligible"` + CollectionDisabledReason string `json:"collectionDisabledReason,omitempty"` } type BatchPreviewResponse struct { @@ -102,6 +104,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba eligible := 0 for _, id := range ids { item := s.previewFromDataset(ctx, id, dataset, false, guard) + item.applyCollectionEligibility(id, dataset) if ruleReason != "" && item.Eligible { item.Eligible = false item.ReasonCode, item.Reason, item.NextAction = purchaserule.CodeCurrentRuleMissing, ruleReason, "refresh" @@ -120,16 +123,19 @@ 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 - latestCollectionByPDD map[uint64]models.CollectionTask + 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 + activeCollectionByPDD map[uint64]uint64 + collectionRuleAvailable bool } // 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. +// Collection eligibility adds bounded active-task and rule queries; pending +// products additionally load their latest task for the user-facing stage. +// 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)), @@ -137,6 +143,7 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba pddByID: make(map[uint64]models.PDDProduct), latestTaskBySYB: make(map[uint64]models.PurchaseTask), latestCollectionByPDD: make(map[uint64]models.CollectionTask), + activeCollectionByPDD: make(map[uint64]uint64), } var sybProducts []models.SYBProduct if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil { @@ -169,6 +176,22 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba for _, pdd := range pddProducts { dataset.pddByID[pdd.ID] = pdd } + var activeCollectionTasks []models.CollectionTask + if err := s.DB.WithContext(ctx). + Where("pdd_product_id IN ? AND status IN ?", pddIDs, []string{models.TaskStatusPending, models.TaskStatusRunning}). + Find(&activeCollectionTasks).Error; err != nil { + return dataset, err + } + for _, task := range activeCollectionTasks { + if task.PDDProductID != nil { + dataset.activeCollectionByPDD[*task.PDDProductID] = task.ID + } + } + var collectionRuleCount int64 + if err := s.DB.WithContext(ctx).Model(&models.CollectionRule{}).Count(&collectionRuleCount).Error; err != nil { + return dataset, err + } + dataset.collectionRuleAvailable = collectionRuleCount > 0 pendingPDDIDs := make([]uint64, 0) for _, pdd := range pddProducts { if pdd.Status == "pending" { diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index 7675394..2793200 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -44,6 +44,62 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) { } } +func TestBatchPreviewExposesIndependentCollectionEligibility(t *testing.T) { + db := testDB(t) + fixture := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, fixture.pdd.ID) + service := testService(db) + preview := func() BatchPreviewItem { + response, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}}) + if err != nil || len(response.Items) != 1 { + t.Fatalf("preview failed: %+v %v", response, err) + } + return response.Items[0] + } + + withoutRule := preview() + if !withoutRule.Eligible || withoutRule.ProcessStage != ProcessStagePurchaseReady || withoutRule.CollectionEligible || withoutRule.CollectionDisabledReason != "没有可用采集规则" { + t.Fatalf("collection eligibility was coupled to purchase readiness: %+v", withoutRule) + } + + rule := models.CollectionRule{Name: "采集规则", ContentJSON: `{}`} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + ready := preview() + if !ready.CollectionEligible || ready.CollectionDisabledReason != "" || ready.PDDProductID == nil || *ready.PDDProductID != fixture.pdd.ID { + t.Fatalf("active idle PDD was not collection eligible: %+v", ready) + } + + activeSlot := uint8(1) + task := models.CollectionTask{ + PDDProductID: &fixture.pdd.ID, RuleID: rule.ID, Status: models.TaskStatusPending, ActiveSlot: &activeSlot, + URLSnapshot: fixture.pdd.URL, GoodsIDSnapshot: fixture.pdd.GoodsID, RuleSnapshot: rule.ContentJSON, + } + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + blocked := preview() + if blocked.CollectionEligible || blocked.CollectionDisabledReason != "已有待执行或执行中的采集任务" { + t.Fatalf("active collection task did not block preview: %+v", blocked) + } + + if err := db.Model(&task).Updates(map[string]any{"status": models.TaskStatusFailed, "active_slot": nil}).Error; err != nil { + t.Fatal(err) + } + if item := preview(); !item.CollectionEligible { + t.Fatalf("terminal collection task kept preview blocked: %+v", item) + } + + if err := db.Model(&models.PDDProduct{}).Where("id = ?", fixture.pdd.ID).Update("status", "disabled").Error; err != nil { + t.Fatal(err) + } + disabled := preview() + if disabled.CollectionEligible || disabled.CollectionDisabledReason != "商品已停用" { + t.Fatalf("disabled product remained collection eligible: %+v", disabled) + } +} + func TestBatchCreateFreezesAbsolutePriceCeilingWhenRatiosAreDisabled(t *testing.T) { db := testDB(t) fixture := seed(t, db, liveCaps(), true) @@ -208,8 +264,8 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) { if matcher.calls != 0 { t.Fatalf("read-only preview called AI matcher %d times", matcher.calls) } - if queries != 5 { - t.Fatalf("batch preview used %d queries, want 5 bounded queries including current purchase rule", queries) + if queries != 7 { + t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries) } if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible { t.Fatalf("unresolved row should remain selectable for full create-time matching: %+v", response) diff --git a/server/app/goauto/purchase/collection_eligibility.go b/server/app/goauto/purchase/collection_eligibility.go new file mode 100644 index 0000000..fe270f8 --- /dev/null +++ b/server/app/goauto/purchase/collection_eligibility.go @@ -0,0 +1,46 @@ +package purchase + +func (item *BatchPreviewItem) applyCollectionEligibility(id uint64, dataset batchPreviewDataset) { + item.CollectionEligible = false + item.CollectionDisabledReason = "" + + syb, ok := dataset.sybByID[id] + if !ok { + item.CollectionDisabledReason = "SYB 商品不存在或已删除" + return + } + if syb.ShopeeProductID == nil { + item.CollectionDisabledReason = "尚未关联蝦皮商品" + return + } + shopee, ok := dataset.shopeeByID[*syb.ShopeeProductID] + if !ok { + item.CollectionDisabledReason = "关联的蝦皮商品不存在" + return + } + if shopee.PDDProductID == nil { + item.CollectionDisabledReason = "尚未关联 PDD 商品" + return + } + + item.PDDProductID = shopee.PDDProductID + pdd, ok := dataset.pddByID[*shopee.PDDProductID] + if !ok { + item.CollectionDisabledReason = "关联的 PDD 商品不存在" + return + } + item.PDDGoodsID, item.PDDTitle = pdd.GoodsID, pdd.Title + if pdd.Status == "disabled" { + item.CollectionDisabledReason = "商品已停用" + return + } + if _, ok := dataset.activeCollectionByPDD[pdd.ID]; ok { + item.CollectionDisabledReason = "已有待执行或执行中的采集任务" + return + } + if !dataset.collectionRuleAvailable { + item.CollectionDisabledReason = "没有可用采集规则" + return + } + item.CollectionEligible = true +} diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 8f371dd..9c7daee 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -239,7 +239,7 @@ export default { }, handleSelectionChange(rows) { this.selectedProducts = rows }, isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return ready.eligible === true && ready.processStage === 'purchase_ready' }, - isCollectionCandidate(row) { const ready = this.purchaseReady(row); return ready.processStage === 'pdd_pending' && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 }, + isCollectionCandidate(row) { const ready = this.purchaseReady(row); return ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 }, rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && (this.isPurchaseCandidate(row) || this.isCollectionCandidate(row)) }, 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)}` }, diff --git a/web/tests/e2e/syb-product-layout.spec.ts b/web/tests/e2e/syb-product-layout.spec.ts index 705c70b..9f2f2d1 100644 --- a/web/tests/e2e/syb-product-layout.spec.ts +++ b/web/tests/e2e/syb-product-layout.spec.ts @@ -13,7 +13,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, processStage: 'purchase_ready', processStageLabel: '可创建采购', processStageReason: '当前条件可以创建采购任务' }, { sybProductId: 2, eligible: false, reason: '尚未关联蝦皮商品', processStage: 'manual_action', processStageLabel: '待人工处理', processStageReason: '尚未关联蝦皮商品' }], 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, pddProductId: 17, eligible: true, collectionEligible: true, minUnitPriceCent: 200, maxUnitPriceCent: 1500, processStage: 'purchase_ready', processStageLabel: '可创建采购', processStageReason: '当前条件可以创建采购任务' }, { sybProductId: 2, eligible: false, collectionEligible: false, collectionDisabledReason: '尚未关联 PDD 商品', reason: '尚未关联蝦皮商品', processStage: 'manual_action', processStageLabel: '待人工处理', processStageReason: '尚未关联蝦皮商品' }], eligibleCount: 1, skippedCount: 1 }}}) return route.fulfill({ json: { code: 200, data: [] }}) }) @@ -57,6 +57,7 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context await page.locator('.el-table__body-wrapper tbody tr').first().locator('.el-checkbox').click() await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible() await expect(page.getByRole('button', { name: '创建采购(1)', exact: true })).toBeEnabled() + await expect(page.getByRole('button', { name: '创建 PDD 采集(1)', exact: true })).toBeEnabled() const widths = await page.locator('.el-table__header-wrapper colgroup col').evaluateAll(cols => cols.map(col => Number(col.getAttribute('width')))) expect(widths[3]).toBe(260) @@ -108,7 +109,7 @@ test('PDD 待采集的下一步打开对应 PDD 商品而不是蝦皮商品', as 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: 31, orderCode: 'SYB-PDD-31', shopeeItemId: '26154802794', shopeeProductId: 9, productTitle: '待采集商品', shopName: '大碼臻選', targetColor: '黑色', targetSize: 'XL', quantity: 1, unitPriceCent: 1000, parseStatus: 'success' }], total: 1, page: 1, pageSize: 20 }}}) - if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 31, pddProductId: 17, eligible: false, reason: 'PDD 商品待采集', processStage: 'pdd_pending', processStageLabel: 'PDD 待采集', processStageReason: 'PDD 商品待采集', processNextAction: 'open_pdd' }], eligibleCount: 0, skippedCount: 1 }}}) + if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 31, pddProductId: 17, eligible: false, collectionEligible: true, reason: 'PDD 商品待采集', processStage: 'pdd_pending', processStageLabel: 'PDD 待采集', processStageReason: 'PDD 商品待采集', processNextAction: 'open_pdd' }], eligibleCount: 0, skippedCount: 1 }}}) return route.fulfill({ json: { code: 200, data: [] }}) }) @@ -186,9 +187,9 @@ test('混选后采购与采集分别提交候选子集且采集按 PDD 去重', if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) { const ids = route.request().postDataJSON().sybProductIds const all = [ - { sybProductId: 201, pddProductId: 301, eligible: true, processStage: 'purchase_ready', processStageLabel: '可创建采购', processStageReason: '当前条件可以创建采购任务' }, - { sybProductId: 202, pddProductId: 302, pddGoodsId: '88002', eligible: false, processStage: 'pdd_pending', processStageLabel: 'PDD 待采集', processStageReason: 'PDD 商品待采集', processNextAction: 'open_pdd' }, - { sybProductId: 203, pddProductId: 302, pddGoodsId: '88002', eligible: false, processStage: 'pdd_pending', processStageLabel: 'PDD 待采集', processStageReason: 'PDD 商品待采集', processNextAction: 'open_pdd' } + { sybProductId: 201, pddProductId: 301, eligible: true, collectionEligible: false, collectionDisabledReason: '已有待执行或执行中的采集任务', processStage: 'purchase_ready', processStageLabel: '可创建采购', processStageReason: '当前条件可以创建采购任务' }, + { sybProductId: 202, pddProductId: 302, pddGoodsId: '88002', eligible: false, collectionEligible: true, processStage: 'pdd_pending', processStageLabel: 'PDD 待采集', processStageReason: 'PDD 商品待采集', processNextAction: 'open_pdd' }, + { sybProductId: 203, pddProductId: 302, pddGoodsId: '88002', eligible: false, collectionEligible: true, processStage: 'pdd_pending', processStageLabel: 'PDD 待采集', processStageReason: 'PDD 商品待采集', processNextAction: 'open_pdd' } ] const items = all.filter(item => ids.includes(item.sybProductId)) return route.fulfill({ json: { code: 200, data: { items, eligibleCount: items.filter(item => item.eligible).length, skippedCount: items.filter(item => !item.eligible).length }}}) @@ -238,7 +239,7 @@ test('去重后超过 100 个 PDD 商品时给出提示且不调用批量接口' 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: rows, total: rows.length, page: 1, pageSize: 200 }}}) if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) { - const items = rows.map((row, index) => ({ sybProductId: row.id, pddProductId: 500 + index, pddGoodsId: `88${index}`, eligible: false, processStage: 'pdd_pending', processStageLabel: 'PDD 待采集', processStageReason: 'PDD 商品待采集', processNextAction: 'open_pdd' })) + const items = rows.map((row, index) => ({ sybProductId: row.id, pddProductId: 500 + index, pddGoodsId: `88${index}`, eligible: false, collectionEligible: true, processStage: 'pdd_pending', processStageLabel: 'PDD 待采集', processStageReason: 'PDD 商品待采集', processNextAction: 'open_pdd' })) return route.fulfill({ json: { code: 200, data: { items, eligibleCount: 0, skippedCount: items.length }}}) } if (url.pathname.endsWith('/api/admin/v1/collection-tasks/batch')) { collectionCalls.push(route.request().postDataJSON()); return route.fulfill({ json: { code: 200, data: { items: [], successCount: 0, failureCount: 0 }}}) }