fix(admin): decouple SYB collection eligibility (#183)

This commit is contained in:
QiuSW
2026-09-01 09:02:24 +08:00
parent dce4a4fc2a
commit c07c3451f1
7 changed files with 174 additions and 47 deletions
+3 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -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 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
+3 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 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`,管理员和采购员可操作 |
+56 -33
View File
@@ -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" {
+58 -2
View File
@@ -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)
@@ -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
}
+1 -1
View File
@@ -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)}` },
+7 -6
View File
@@ -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 }}}) }