diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 3c4d05f..cafd0a9 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 6863d83cfbd8dd6577475374f09a799a6f358346 -synchronized_at: 2026-08-23T15:56:21Z +wiki_revision: 9bb0da18b68d368b37c060af9867983ace6b49f6 +synchronized_at: 2026-08-24T03:42:12Z # 架构与代码地图 @@ -121,7 +121,7 @@ Android Portal/Agent | SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` | | SYB 后台导入任务、进度、单任务互斥与启动恢复 | `server/app/goauto/sybimport/sync_run.go`、`sync_run_handler.go`;表 `syb_sync_run`,迁移 `server/cmd/migrate/migration/version-local/1786701000000_syb_sync_run.go` | | 采购任务数据与类型化规则契约 | `server/app/goauto/models/purchase.go`、`server/app/goauto/purchasecontract/`;迁移 `server/cmd/migrate/migration/version-local/1786701100000_purchase_contract.go` | -| 采购任务单条/批量预检与创建、租约、attempt 幂等、Admin 只读查询和人工处置状态机 | `server/app/goauto/purchase/`;批量编排复用单任务事务且不新增迁移;既有追加迁移为 `server/cmd/migrate/migration/version-local/1786701200000_purchase_state_machine.go` | +| 采购任务单条/批量预检与创建、租约、attempt 幂等、Admin 只读查询和人工处置状态机 | `server/app/goauto/purchase/`;`batch-preview` 通过批量预加载 SYB、蝦皮、PDD 与最新任务执行快速只读预检,不访问 AI;`batch-create` 仍按最新数据逐项完整复核并可在必要时调用 AI;既有追加迁移为 `server/cmd/migrate/migration/version-local/1786701200000_purchase_state_machine.go` | | 采购规格标准化匹配与 AI Provider 设置 | `server/app/goauto/aimatching/`;服务端先做繁简、空白/全半角/大小写和公斤/斤的唯一确定性匹配,再按需调用单一 OpenAI-compatible Provider;`1786701300000_ai_matching_setting.go` 创建设置表,`1786701400000_ai_matching_setting_plain_api_key.go` 将原加密列迁移为内部明文 `api_key`,仅管理员读取 | | 任务领取、结果、重置与删除 | `server/app/goauto/task/` | | 管理端基线 | `web/`(go-admin-ui v3.0.0) | diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 9c06daa..ee1fbcc 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: dcdf1dab84108752889e1c75bfd6bbc2a207ba01 -synchronized_at: 2026-08-23T15:56:25Z +wiki_revision: 14e4596838288a4685ffac6b55b17fcecba40326 +synchronized_at: 2026-08-24T03:42:20Z # 业务规则与术语 @@ -119,6 +119,7 @@ synchronized_at: 2026-08-23T15:56:25Z - 演练规则在服务端契约层拒绝改地址、创建订单和核单动作;正式动作还必须通过版本化能力协商。规则只包含类型化动作,禁止任意脚本。 - 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。 - Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。 +- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取、已确认映射和本地确定性匹配,禁止调用 AI Provider。正式创建仍按最新数据完整复核,只有创建阶段无法唯一确定规格时才允许调用 AI。 - 批量创建使用服务端内置并经契约校验的正式采购规则,每条任务保存不可变快照;设备默认人工指定,也可以留空由符合能力的空闲设备领取。 - 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:已确认颜色映射时按该颜色人民币价格计算;待规格探测时用可用颜色最低价的 0.2 倍到最高价的 1.5 倍,参考价取最高价。没有可用颜色价格时不能创建。 - 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。 diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index dcac62d..3819b68 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -80,11 +80,15 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba if err != nil { return BatchPreviewResponse{}, err } + dataset, err := s.loadBatchPreviewDataset(ctx, ids) + if err != nil { + return BatchPreviewResponse{}, internal(err) + } deviceErr := s.validateBatchDevice(ctx, req.DeviceID) items := make([]BatchPreviewItem, 0, len(ids)) eligible := 0 for _, id := range ids { - item := s.previewOne(ctx, id) + item := s.previewFromDataset(ctx, id, dataset, false) if item.Eligible && deviceErr != nil { item.Eligible = false item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(deviceErr) @@ -97,6 +101,73 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba return BatchPreviewResponse{Items: items, EligibleCount: eligible, SkippedCount: len(items) - eligible}, nil } +type batchPreviewDataset struct { + sybByID map[uint64]models.SYBProduct + shopeeByID map[uint64]models.ShopeeProduct + pddByID map[uint64]models.PDDProduct + latestTaskBySYB map[uint64]models.PurchaseTask +} + +// 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. +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), + } + var sybProducts []models.SYBProduct + if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil { + return dataset, err + } + shopeeIDs := make([]uint64, 0, len(sybProducts)) + for _, syb := range sybProducts { + dataset.sybByID[syb.ID] = syb + if syb.ShopeeProductID != nil { + shopeeIDs = append(shopeeIDs, *syb.ShopeeProductID) + } + } + if len(shopeeIDs) > 0 { + var shopeeProducts []models.ShopeeProduct + if err := s.DB.WithContext(ctx).Where("id IN ?", shopeeIDs).Find(&shopeeProducts).Error; err != nil { + return dataset, err + } + pddIDs := make([]uint64, 0, len(shopeeProducts)) + for _, shopee := range shopeeProducts { + dataset.shopeeByID[shopee.ID] = shopee + if shopee.PDDProductID != nil { + pddIDs = append(pddIDs, *shopee.PDDProductID) + } + } + if len(pddIDs) > 0 { + var pddProducts []models.PDDProduct + if err := s.DB.WithContext(ctx).Where("id IN ?", pddIDs).Find(&pddProducts).Error; err != nil { + return dataset, err + } + for _, pdd := range pddProducts { + dataset.pddByID[pdd.ID] = pdd + } + } + } + var tasks []models.PurchaseTask + if err := s.DB.WithContext(ctx). + Where("syb_product_id IN ? AND id = (SELECT MAX(pt2.id) FROM purchase_task pt2 WHERE pt2.syb_product_id = purchase_task.syb_product_id)", ids). + Find(&tasks).Error; err != nil { + return dataset, err + } + for _, task := range tasks { + if task.SYBProductID == nil { + continue + } + if _, exists := dataset.latestTaskBySYB[*task.SYBProductID]; !exists { + dataset.latestTaskBySYB[*task.SYBProductID] = task + } + } + return dataset, nil +} + func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (BatchCreateResponse, error) { if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil { return BatchCreateResponse{}, fail(CodeInvalidRequest, "requestId 无效") @@ -188,9 +259,19 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64) err } func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem { + dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id}) + if err != nil { + item := BatchPreviewItem{SYBProductID: id} + item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry" + return item + } + return s.previewFromDataset(ctx, id, dataset, true) +} + +func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool) BatchPreviewItem { item := BatchPreviewItem{SYBProductID: id} - var syb models.SYBProduct - if err := s.DB.WithContext(ctx).First(&syb, id).Error; err != nil { + syb, found := dataset.sybByID[id] + if !found { item.ReasonCode, item.Reason, item.NextAction = "SYB_PRODUCT_NOT_FOUND", "SYB 商品不存在或已被删除", "refresh" return item } @@ -204,8 +285,8 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem { item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_LINKED", "尚未关联蝦皮商品,请先重新导入或处理商品档案", "refresh" return item } - var shopee models.ShopeeProduct - if err := s.DB.WithContext(ctx).First(&shopee, *syb.ShopeeProductID).Error; err != nil { + shopee, found := dataset.shopeeByID[*syb.ShopeeProductID] + if !found { item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee" return item } @@ -214,8 +295,8 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem { return item } item.PDDProductID = shopee.PDDProductID - var pdd models.PDDProduct - if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil { + pdd, found := dataset.pddByID[*shopee.PDDProductID] + if !found { item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_FOUND", "关联的拼多多商品不存在,请先重新匹配", "open_mapping" return item } @@ -233,12 +314,17 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem { } if source == "unresolved" { if archiveUsable { - matched, matchErr := s.matcher().Resolve(ctx, aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}) - if matchErr != nil { - item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr)) - return item + request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes} + if allowAI { + matched, matchErr := s.matcher().Resolve(ctx, request) + if matchErr != nil { + item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr)) + return item + } + item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize + } else if matched, ok := aimatching.DeterministicMatch(request); ok { + item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize } - item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize } } reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor) @@ -247,9 +333,7 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem { return item } item.ReferenceUnitPriceCent, item.MinUnitPriceCent, item.MaxUnitPriceCent, item.Currency = reference, minPrice, maxPrice, "CNY" - var previous models.PurchaseTask - err = s.DB.WithContext(ctx).Where("syb_product_id = ?", syb.ID).Order("id DESC").First(&previous).Error - if err == nil { + if previous, found := dataset.latestTaskBySYB[syb.ID]; found { item.ActiveTaskID = &previous.ID switch previous.Status { case models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusRehearsalCompleted: @@ -266,9 +350,6 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem { item.ReasonCode, item.Reason, item.NextAction = CodeStateConflict, "该商品已有未结束的采购任务", "open_task" return item } - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry" - return item } item.ActiveTaskID = nil item.Eligible = true diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index c88706f..15c2a3e 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -106,6 +106,46 @@ func TestBatchPreviewRejectsIncompatibleDevice(t *testing.T) { } } +func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) { + db := testDB(t) + fixture := seed(t, db, liveCaps(), false) + setCollectedPDDPrice(t, db, fixture.pdd.ID) + second := fixture.syb + second.ID = 0 + second.OrderCode = "SYB-2" + second.DetailID = 2 + second.TargetColor = "深黑色" + if err := db.Create(&second).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBProduct{}).Where("id = ?", fixture.syb.ID).Update("target_color", "深黑色").Error; err != nil { + t.Fatal(err) + } + matcher := &countingRetryMatcher{} + service := testService(db) + service.Matcher = matcher + queries := 0 + callbackName := "test:count-batch-preview-queries" + if err := db.Callback().Query().Before("gorm:query").Register(callbackName, func(*gorm.DB) { queries++ }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Callback().Query().Remove(callbackName) }) + + response, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID, second.ID}}) + if err != nil { + t.Fatal(err) + } + if matcher.calls != 0 { + t.Fatalf("read-only preview called AI matcher %d times", matcher.calls) + } + if queries != 4 { + t.Fatalf("batch preview used %d queries, want 4 bulk queries", 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) + } +} + func setCollectedPDDPrice(t *testing.T, db *gorm.DB, productID uint64) { t.Helper() if err := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { diff --git a/web/src/utils/request.js b/web/src/utils/request.js index 2d7b0db..3f2e9bb 100644 --- a/web/src/utils/request.js +++ b/web/src/utils/request.js @@ -78,20 +78,24 @@ service.interceptors.response.use( }) return false } else if (code === 400 || code === 403) { - ElMessage({ - message: response.data.msg, - type: 'error', - duration: 5 * 1000 - }) + if (!response.config?.suppressErrorMessage) { + ElMessage({ + message: response.data.msg, + type: 'error', + duration: 5 * 1000 + }) + } return Promise.reject(new Error(response.data.msg)) } else if (code !== 200) { // Notification.error({ // title: response.data.msg // }) - ElMessage({ - message: response.data.msg, - type: 'error' - }) + if (!response.config?.suppressErrorMessage) { + ElMessage({ + message: response.data.msg, + type: 'error' + }) + } return Promise.reject('error') } else { return response.data @@ -99,7 +103,7 @@ service.interceptors.response.use( }, error => { if (error.message === 'Network Error') { - if (!error.config?.suppressNetworkError) { + if (!error.config?.suppressNetworkError && !error.config?.suppressErrorMessage) { ElMessage({ message: '服务器连接异常,请检查服务器!', type: 'error', @@ -112,11 +116,13 @@ service.interceptors.response.use( const message = error.response?.data?.message || error.response?.data?.msg || error.message - ElMessage({ - message, - type: 'error', - duration: 5 * 1000 - }) + if (!error.config?.suppressErrorMessage) { + ElMessage({ + message, + type: 'error', + duration: 5 * 1000 + }) + } return Promise.reject(error) } diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 652127a..3dab71b 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -153,7 +153,7 @@ export default { data() { return { loading: false, products: [], selectedProducts: [], total: 0, - purchaseReadiness: {}, purchaseReadinessLoading: false, + purchaseReadiness: {}, purchaseReadinessLoading: false, loadGeneration: 0, purchaseDevices: [], purchaseDialog: { open: false, loading: false, saving: false, ids: [], deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 }, purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 }, @@ -185,23 +185,28 @@ export default { beforeUnmount() { this.stopSyncPolling() }, methods: { async load(allowNetworkRetry = true) { + const generation = ++this.loadGeneration this.loading = true this.selectedProducts = [] this.$refs.productTable?.clearSelection() const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {} try { const r = await listSybProducts(this.query, requestOptions) + if (generation !== this.loadGeneration) return this.products = r.data.items this.total = r.data.total - await this.loadPurchaseReadiness(requestOptions) + this.loading = false + void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation) } catch (error) { + if (generation !== this.loadGeneration) return if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) { await new Promise(resolve => setTimeout(resolve, 1000)) + if (generation !== this.loadGeneration) return return this.load(false) } throw error } finally { - this.loading = false + if (generation === this.loadGeneration) this.loading = false } }, search() { this.query.page = 1; this.load() }, @@ -211,14 +216,21 @@ 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)}` }, purchaseActionLabel(item) { return { open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' }, - async loadPurchaseReadiness(requestOptions = {}) { + async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration) { + if (generation !== this.loadGeneration) return this.purchaseReadiness = {} - if (!this.canPurchase || !this.products.length) return + if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return } this.purchaseReadinessLoading = true try { - const r = await previewPurchaseTasks({ sybProductIds: this.products.map(item => item.id) }, requestOptions) + const r = await previewPurchaseTasks({ sybProductIds: ids }, { ...requestOptions, suppressErrorMessage: true }) + if (generation !== this.loadGeneration) return this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item])) - } finally { this.purchaseReadinessLoading = false } + } catch { + if (generation !== this.loadGeneration) return + this.purchaseReadiness = Object.fromEntries(ids.map(id => [id, { sybProductId: id, eligible: false, reason: '采购准备检查失败,请点击刷新重试', nextAction: 'refresh' }])) + } finally { + if (generation === this.loadGeneration) this.purchaseReadinessLoading = false + } }, 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' } }, diff --git a/web/tests/e2e/syb-network-retry.spec.ts b/web/tests/e2e/syb-network-retry.spec.ts index 1a6bd5b..e135370 100644 --- a/web/tests/e2e/syb-network-retry.spec.ts +++ b/web/tests/e2e/syb-network-retry.spec.ts @@ -40,3 +40,75 @@ test('SYB 商品业务错误不重试', async({ page, context }) => { await expect(page.getByText('测试业务错误')).toBeVisible() expect(listCalls).toBe(1) }) + +test('SYB 列表先展示,采购准备在独立请求完成后更新', async({ page, context }) => { + await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) + await page.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: 73, orderCode: 'FAST-73', shopeeItemId: '10073', productTitle: '先显示的商品', quantity: 1, parseStatus: 'success' }], total: 1, page: 1, pageSize: 20 }}}) + if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) { + await new Promise(resolve => setTimeout(resolve, 3000)) + return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 73, eligible: true, minUnitPriceCent: 100, maxUnitPriceCent: 300 }], eligibleCount: 1, skippedCount: 0 }}}) + } + return route.fulfill({ json: { code: 200, data: [] }}) + }) + + await page.goto('http://localhost:9527/#/syb-products/index') + await expect(page.getByText('先显示的商品')).toBeVisible({ timeout: 2000 }) + await expect(page.getByText('正在检查')).toBeVisible() + await expect(page.getByText('可以创建')).toBeVisible({ timeout: 5000 }) +}) + +test('采购准备失败不清空 SYB 列表且不弹全局错误', async({ page, context }) => { + await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) + await page.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: 74, orderCode: 'FAIL-74', shopeeItemId: '10074', productTitle: '仍然可见的商品', quantity: 1, parseStatus: 'success' }], total: 1, page: 1, pageSize: 20 }}}) + if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) return route.fulfill({ status: 500, json: { code: 500, msg: '预检测试失败' }}) + return route.fulfill({ json: { code: 200, data: [] }}) + }) + + await page.goto('http://localhost:9527/#/syb-products/index') + await expect(page.getByText('仍然可见的商品')).toBeVisible() + await expect(page.getByText('采购准备检查失败,请点击刷新重试')).toBeVisible() + await expect(page.getByText('预检测试失败')).toHaveCount(0) +}) + +test('新的查询结果不会被旧采购预检覆盖', async({ page, context }) => { + await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) + await page.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')) { + const searched = url.searchParams.get('keyword') === '新商品' + const item = searched + ? { id: 76, orderCode: 'NEW-76', shopeeItemId: '10076', productTitle: '新商品', quantity: 1, parseStatus: 'success' } + : { id: 75, orderCode: 'OLD-75', shopeeItemId: '10075', productTitle: '旧商品', quantity: 1, parseStatus: 'success' } + return route.fulfill({ json: { code: 200, data: { items: [item], total: 1, page: 1, pageSize: 20 }}}) + } + if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) { + const [id] = route.request().postDataJSON().sybProductIds + if (id === 75) { + await new Promise(resolve => setTimeout(resolve, 1200)) + return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 75, eligible: true }], eligibleCount: 1, skippedCount: 0 }}}) + } + return route.fulfill({ json: { code: 200, data: { items: [{ sybProductId: 76, eligible: false, reason: '新商品暂不可创建' }], eligibleCount: 0, skippedCount: 1 }}}) + } + return route.fulfill({ json: { code: 200, data: [] }}) + }) + + await page.goto('http://localhost:9527/#/syb-products/index') + await expect(page.getByText('旧商品')).toBeVisible() + await page.getByPlaceholder('订单号、虾皮商品ID或商品标题').fill('新商品') + await page.getByRole('button', { name: '查询' }).click() + await expect(page.getByText('新商品', { exact: true })).toBeVisible() + await expect(page.getByText('新商品暂不可创建')).toBeVisible() + await page.waitForTimeout(1500) + await expect(page.getByText('新商品暂不可创建')).toBeVisible() + await expect(page.getByText('可以创建', { exact: true })).toHaveCount(0) +})