From 3ef0f72ff965f402c66eb980ffd6b9b260f5b2fb Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 1 Sep 2026 12:13:33 +0800 Subject: [PATCH] feat: batch AI match SYB purchase specs (#188) --- docs/03-business-rules-and-glossary.md | 6 +- docs/08-agent-api-contract.md | 7 +- server/app/goauto/access/purchaser.go | 1 + server/app/goauto/access/purchaser_test.go | 9 + .../app/goauto/purchase/batch_spec_match.go | 196 ++++++++++++++++++ .../goauto/purchase/batch_spec_match_test.go | 132 ++++++++++++ server/app/goauto/purchase/handler.go | 20 ++ server/app/goauto/purchase/router.go | 1 + .../shopeeproduct/batch_resolved_mapping.go | 75 +++++++ web/src/api/goauto/purchase-tasks.js | 1 + web/src/views/goauto/syb-products/index.vue | 63 +++++- web/tests/e2e/syb-product-layout.spec.ts | 69 +++++- 12 files changed, 553 insertions(+), 27 deletions(-) create mode 100644 server/app/goauto/purchase/batch_spec_match.go create mode 100644 server/app/goauto/purchase/batch_spec_match_test.go create mode 100644 server/app/goauto/shopeeproduct/batch_resolved_mapping.go diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 6badf7a..dc5326d 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: 36d7285beb3f0c8441e38b4e9e81e5cf7256b603 -synchronized_at: 2026-09-01T01:50:05Z +wiki_revision: 084dae8190b765758cc77a59732215bd8a9ca6fe +synchronized_at: 2026-09-01T04:05:25Z # 业务规则与术语 @@ -31,7 +31,7 @@ synchronized_at: 2026-09-01T01:50:05Z - 关联 PDD 商品时校验目标存在且非 `disabled`;不提供手工输入商品 ID 的入口,只能通过搜索选择。 - 参考售价 `sale_price_cent` 为整数分,`currency` 为 ISO 4217 代码;币种取系统配置默认值,不逐商品选择,缺省回退为 `TWD`。 - 规格值来源分 `import`(SYB 导入,不可人工删除,只能清除映射)与 `manual`(人工添加,可删除);导入与人工值按「维度 + 名称」合并,不重复创建。 -- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称完全相等自动预填)、`ai_match`(AI 建议);`exact_match` 与 `ai_match` 一律以 `pending` 状态写入,必须人工确认后才生效。 +- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称完全相等自动预填)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配是独立例外:只有 `ai_match` 置信度存在且达到服务端阈值、候选与原因完整时才可直接持久化为 `confirmed`,低置信度或异常不得改写现有映射。 - 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。 - 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。 - PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 53131dd..4479566 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: 0f448673dbec00a351c397d30926f0c5090a1141 -synchronized_at: 2026-09-01T01:50:29Z +wiki_revision: 44f936706dfd36b0281215a239b790bbe50aaf77 +synchronized_at: 2026-09-01T04:09:50Z # MVP 共享 API 契约 @@ -54,7 +54,7 @@ POST /api/admin/v1/shopee-products/batch-delete 创建与关联/映射相关写操作均提交 `requestId` 做幂等重放;重放请求返回相同结果并标记 `replayed`。创建请求提交 `shopeeItemId`、`title`、`shopName`,可选 `pddProductId` 和 `specs`;`shopeeItemId` 重复时返回 `SHOPEE_ITEM_ID_EXISTS` 和已存在商品 ID。`link-pdd` 校验 PDD 商品存在且非 `disabled`,否则分别返回 `PDD_PRODUCT_NOT_FOUND` 或 `PDD_PRODUCT_DISABLED`;关联成功后返回值包含 `sharedByPddCount`,表示当前共用同一 PDD 商品的虾皮商品数。 -`specs` 结构同 PDD 商品的维度/规格值形状,但规格值额外携带 `source`(`import` / `manual`)与可选的 `mapping`(`pddValue`、`source`、`status`、`confidence`、`reason`)。新增规格值固定为 `manual` 来源;删除规格值仅允许 `manual` 来源,`import` 来源返回 `SPEC_VALUE_NOT_MANUAL`。设置映射时,`exact_match` 与 `ai_match` 来源一律写入 `pending` 状态,与请求体中的 `status` 无关;只有 `manual` 来源可以直接写入 `confirmed`。`confirm-exact-matches` 仅确认 `source=exact_match` 且状态为 `pending` 的映射,不影响 `ai_match`。 +`specs` 结构同 PDD 商品的维度/规格值形状,但规格值额外携带 `source`(`import` / `manual`)与可选的 `mapping`(`pddValue`、`source`、`status`、`confidence`、`reason`)。新增规格值固定为 `manual` 来源;删除规格值仅允许 `manual` 来源,`import` 来源返回 `SPEC_VALUE_NOT_MANUAL`。设置映射时,`exact_match` 与 `ai_match` 来源一律写入 `pending` 状态,与请求体中的 `status` 无关;只有 `manual` 来源可以直接写入 `confirmed`。`confirm-exact-matches` 仅确认 `source=exact_match` 且状态为 `pending` 的映射,不影响 `ai_match`。该通用入口不因 #188 改变;#188 仅通过下述采购批量规格匹配接口的独立写入路径,在高置信度门槛通过时写入 `confirmed`。 `preview-auto-size` 是只读计算接口(使用 `POST` 触发计算,不写数据库),无需 `requestId`。它读取当前蝦皮尺码与关联 PDD 的可选尺码,只返回格式统一后唯一确定的匹配;响应含 `items[]`(`valueName`、可选 `pddValue`、`status=preserved|matched|pending`、`reason`)、`pddValues`、`matchedCount` 和 `pendingCount`。已确认且目标仍存在的映射标为 `preserved`;无唯一结果标为 `pending`。Admin 的显式“保存修改”仍通过既有设置/确认接口落库。 @@ -502,6 +502,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | `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`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` 与可选禁用原因 `collectionDisabledReason`,不创建任务 | +| `POST` | `/api/admin/v1/purchase-tasks/batch-spec-match` | `sybProductIds`(1~100);仅处理当前为 `color_mapping` 的明细,逐条返回 `auto_confirmed` / `pending` / `failed` / `skipped`。只有 `ai_match` 置信度达到 `autoConfirmMinConfidence` 且理由非空时,才通过独立路径把该明细所需映射写为 `confirmed`;低置信度和 Provider 异常不改写现有映射 | | `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/access/purchaser.go b/server/app/goauto/access/purchaser.go index 8b8f567..dfb40f2 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -97,6 +97,7 @@ var AdminAPIs = []APIPermission{ {"查看采购任务", "/api/admin/v1/purchase-tasks", "GET", true}, {"预检批量采购", "/api/admin/v1/purchase-tasks/batch-preview", "POST", true}, + {"批量 AI 匹配采购规格", "/api/admin/v1/purchase-tasks/batch-spec-match", "POST", true}, {"批量创建采购任务", "/api/admin/v1/purchase-tasks/batch", "POST", true}, {"批量重试采购任务", "/api/admin/v1/purchase-tasks/batch-retry", "POST", true}, {"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true}, diff --git a/server/app/goauto/access/purchaser_test.go b/server/app/goauto/access/purchaser_test.go index a7bd8a6..30f8125 100644 --- a/server/app/goauto/access/purchaser_test.go +++ b/server/app/goauto/access/purchaser_test.go @@ -53,3 +53,12 @@ func TestPurchaserMayCreateStockPurchase(t *testing.T) { } t.Fatal("missing purchaser stock purchase permission") } + +func TestPurchaserMayRunBatchSpecMatch(t *testing.T) { + for _, permission := range PurchaserAPIs() { + if permission.Method == "POST" && permission.Path == "/api/admin/v1/purchase-tasks/batch-spec-match" { + return + } + } + t.Fatal("missing purchaser batch spec match permission") +} diff --git a/server/app/goauto/purchase/batch_spec_match.go b/server/app/goauto/purchase/batch_spec_match.go new file mode 100644 index 0000000..c650c26 --- /dev/null +++ b/server/app/goauto/purchase/batch_spec_match.go @@ -0,0 +1,196 @@ +package purchase + +import ( + "context" + "errors" + "fmt" + "strings" + + "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/purchaserule" + "go-admin/app/goauto/shopeeproduct" + + "github.com/google/uuid" +) + +const ( + BatchSpecMatchAutoConfirmed = "auto_confirmed" + BatchSpecMatchPending = "pending" + BatchSpecMatchFailed = "failed" + BatchSpecMatchSkipped = "skipped" +) + +type BatchSpecMatchRequest struct { + SYBProductIDs []uint64 `json:"sybProductIds"` +} + +type BatchSpecMatchItem struct { + SYBProductID uint64 `json:"sybProductId"` + OrderCode string `json:"orderCode,omitempty"` + ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"` + Status string `json:"status"` + Source string `json:"source,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` + Reason string `json:"reason"` +} + +type BatchSpecMatchResponse struct { + Items []BatchSpecMatchItem `json:"items"` + AutoConfirmedCount int `json:"autoConfirmedCount"` + PendingCount int `json:"pendingCount"` + FailedCount int `json:"failedCount"` + SkippedCount int `json:"skippedCount"` +} + +func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest) (BatchSpecMatchResponse, error) { + ids, err := validateBatchIDs(req.SYBProductIDs) + if err != nil { + return BatchSpecMatchResponse{}, err + } + dataset, err := s.loadBatchPreviewDataset(ctx, ids) + if err != nil { + return BatchSpecMatchResponse{}, internal(err) + } + guard, ruleReason, err := s.readOnlyPriceGuard(ctx) + if err != nil { + return BatchSpecMatchResponse{}, err + } + settings, err := aimatching.NewService(s.DB).Settings(ctx) + if err != nil { + return BatchSpecMatchResponse{}, internal(err) + } + + response := BatchSpecMatchResponse{Items: make([]BatchSpecMatchItem, 0, len(ids))} + for _, id := range ids { + item := BatchSpecMatchItem{SYBProductID: id, Status: BatchSpecMatchFailed} + syb, found := dataset.sybByID[id] + if found { + item.OrderCode, item.ShopeeProductID = syb.OrderCode, syb.ShopeeProductID + } + preview := s.previewFromDataset(ctx, id, dataset, false, guard) + if ruleReason != "" && preview.Eligible { + preview.Eligible, preview.ReasonCode, preview.Reason, preview.NextAction = false, purchaserule.CodeCurrentRuleMissing, ruleReason, "refresh" + } + stage := processStageFromDataset(id, dataset, preview) + if stage.Stage != ProcessStageColorMapping { + item.Status, item.Reason = BatchSpecMatchSkipped, "当前不是颜色待匹配阶段,无需处理" + response.SkippedCount++ + response.Items = append(response.Items, item) + continue + } + + shopee := dataset.shopeeByID[*syb.ShopeeProductID] + pdd := dataset.pddByID[*shopee.PDDProductID] + candidates, usable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize) + if !usable { + item.Reason = "关联的 PDD 商品没有完整可选规格" + response.FailedCount++ + response.Items = append(response.Items, item) + continue + } + mappedColor, mappedSize, _ := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize) + if mappedColor != "" && !containsValue(candidates.Colors, mappedColor) { + mappedColor = "" + } + if mappedSize != "" && !containsValue(candidates.Sizes, mappedSize) { + mappedSize = "" + } + request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes} + if mappedColor == "" { + request.TargetColor = syb.TargetColor + } + if mappedSize == "" { + request.TargetSize = syb.TargetSize + } + matched, matchErr := s.matcher().Resolve(ctx, request) + if matchErr != nil { + item.Reason = batchSpecMatchErrorMessage(matchErr) + response.FailedCount++ + response.Items = append(response.Items, item) + continue + } + item.Source, item.Confidence = matched.Source, matched.Decision.Confidence + autoConfirm := matched.Source == aimatching.SourceAI && matched.Decision.Confidence != nil && *matched.Decision.Confidence >= settings.AutoConfirmMinConfidence && strings.TrimSpace(matched.Decision.Reason) != "" + if !autoConfirm { + item.Status, item.Reason = BatchSpecMatchPending, "匹配结果未达到自动确认阈值,请人工确认" + if strings.TrimSpace(matched.Decision.Reason) != "" { + item.Reason += ":" + strings.TrimSpace(matched.Decision.Reason) + } + response.PendingCount++ + response.Items = append(response.Items, item) + continue + } + writes, buildErr := resolvedMappingWrites(shopee.SpecsJSON, request, matched, shopeeproduct.MappingStatusConfirmed) + if buildErr != nil { + item.Reason = buildErr.Error() + response.FailedCount++ + response.Items = append(response.Items, item) + continue + } + if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), settings.AutoConfirmMinConfidence, writes); err != nil { + item.Reason = "规格映射保存失败,请刷新后重试" + response.FailedCount++ + response.Items = append(response.Items, item) + continue + } + if err := s.DB.WithContext(ctx).First(&shopee, shopee.ID).Error; err == nil { + dataset.shopeeByID[shopee.ID] = shopee + } + item.Status, item.Reason = BatchSpecMatchAutoConfirmed, strings.TrimSpace(matched.Decision.Reason) + response.AutoConfirmedCount++ + response.Items = append(response.Items, item) + } + return response, nil +} + +func resolvedMappingWrites(raw string, request aimatching.MatchRequest, matched aimatching.MatchResult, status string) ([]shopeeproduct.ResolvedMappingItem, error) { + specs, err := shopeeproduct.Unmarshal(raw) + if err != nil { + return nil, errors.New("蝦皮商品规格数据无效") + } + writes := make([]shopeeproduct.ResolvedMappingItem, 0, 2) + add := func(role, target, pddValue string) error { + if strings.TrimSpace(target) == "" { + return nil + } + for _, dimension := range specs { + if dimension.Role != role { + continue + } + for _, value := range dimension.Values { + if value.Name == target { + writes = append(writes, shopeeproduct.ResolvedMappingItem{Dimension: dimension.Name, ValueName: target, PDDValue: pddValue, Source: matched.Source, Status: status, Confidence: matched.Decision.Confidence, Reason: matched.Decision.Reason}) + return nil + } + } + } + return fmt.Errorf("蝦皮商品中未找到目标规格“%s”", target) + } + if err := add(shopeeproduct.RoleColor, request.TargetColor, matched.MappedColor); err != nil { + return nil, err + } + if err := add(shopeeproduct.RoleSize, request.TargetSize, matched.MappedSize); err != nil { + return nil, err + } + if len(writes) == 0 { + return nil, errors.New("没有需要写入的规格映射") + } + return writes, nil +} + +func batchSpecMatchErrorMessage(err error) string { + var matchErr *aimatching.Error + if errors.As(err, &matchErr) { + switch matchErr.Code { + case aimatching.CodeNotConfigured: + return "AI 匹配未启用或未配置,请先在设置中启用" + case aimatching.CodeNoMatch: + return "AI 未找到可靠的 PDD 规格" + case aimatching.CodeProviderUnavailable: + return "AI 匹配服务暂时不可用,请稍后重试" + case aimatching.CodeInvalidSetting: + return "AI 匹配设置无效,请检查配置" + } + } + return "规格匹配失败,请稍后重试" +} diff --git a/server/app/goauto/purchase/batch_spec_match_test.go b/server/app/goauto/purchase/batch_spec_match_test.go new file mode 100644 index 0000000..36e6dd3 --- /dev/null +++ b/server/app/goauto/purchase/batch_spec_match_test.go @@ -0,0 +1,132 @@ +package purchase + +import ( + "context" + "errors" + "testing" + + "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" +) + +type batchSpecMatcher struct { + results []aimatching.MatchResult + errs []error + calls int +} + +func (matcher *batchSpecMatcher) Resolve(_ context.Context, _ aimatching.MatchRequest) (aimatching.MatchResult, error) { + at := matcher.calls + matcher.calls++ + if at < len(matcher.errs) && matcher.errs[at] != nil { + return aimatching.MatchResult{}, matcher.errs[at] + } + return matcher.results[at], nil +} + +func unresolvedBatchSpecFixture(t *testing.T) (*Service, fixture) { + t.Helper() + db := testDB(t) + f := seed(t, db, liveCaps(), true) + pddSpecs := `[{"name":"颜色","role":"color","values":[{"name":"米白色","selectable":true,"priceCent":2000},{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]` + if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", pddSpecs).Error; err != nil { + t.Fatal(err) + } + shopeeSpecs := `[{"name":"颜色分类","role":"color","values":[{"name":"象牙白","source":"import","mapping":{"pddValue":"旧白色","source":"manual","status":"confirmed"}}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import","mapping":{"pddValue":"XL","source":"manual","status":"confirmed"}}]}]` + if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", shopeeSpecs).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil { + t.Fatal(err) + } + f.syb.TargetColor = "象牙白" + return testService(db), f +} + +func aiBatchResult(confidence float64) aimatching.MatchResult { + request := aimatching.MatchRequest{TargetColor: "象牙白", Colors: []string{"米白色", "白色"}, Sizes: []string{"XL"}} + result := aimatching.RecordedMatch(request, aimatching.SourceAI, "米白色", "", "颜色语义与候选唯一对应") + result.Decision.Confidence = &confidence + return result +} + +func savedColorMapping(t *testing.T, service *Service, productID uint64) *shopeeproduct.Mapping { + t.Helper() + var product models.ShopeeProduct + if err := service.DB.First(&product, productID).Error; err != nil { + t.Fatal(err) + } + specs, err := shopeeproduct.Unmarshal(product.SpecsJSON) + if err != nil { + t.Fatal(err) + } + return specs[0].Values[0].Mapping +} + +func TestBatchSpecMatchAutoConfirmsHighConfidenceAndRefreshesReadiness(t *testing.T) { + service, f := unresolvedBatchSpecFixture(t) + duplicate := f.syb + duplicate.ID = 0 + duplicate.OrderCode = "SYB-2" + duplicate.DetailID++ + if err := service.DB.Create(&duplicate).Error; err != nil { + t.Fatal(err) + } + matcher := &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.95)}} + service.Matcher = matcher + + response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID, duplicate.ID}}) + if err != nil { + t.Fatal(err) + } + if response.AutoConfirmedCount != 1 || response.SkippedCount != 1 || matcher.calls != 1 { + t.Fatalf("unexpected mixed result: %+v calls=%d", response, matcher.calls) + } + mapping := savedColorMapping(t, service, f.shopee.ID) + if mapping == nil || mapping.Status != shopeeproduct.MappingStatusConfirmed || mapping.Source != shopeeproduct.MappingSourceAIMatch || mapping.Confidence == nil || *mapping.Confidence != 0.95 { + t.Fatalf("high-confidence mapping was not independently confirmed: %+v", mapping) + } + preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}}) + if err != nil || len(preview.Items) != 1 || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady { + t.Fatalf("readiness did not reflect confirmed mapping: %+v err=%v", preview, err) + } +} + +func TestBatchSpecMatchLeavesLowConfidenceForManualHandling(t *testing.T) { + service, f := unresolvedBatchSpecFixture(t) + service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}} + + response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}}) + if err != nil || response.PendingCount != 1 || response.AutoConfirmedCount != 0 { + t.Fatalf("unexpected low-confidence result: %+v err=%v", response, err) + } + mapping := savedColorMapping(t, service, f.shopee.ID) + if mapping == nil || mapping.PDDValue != "旧白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed { + t.Fatalf("low-confidence result changed the saved mapping: %+v", mapping) + } + preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}}) + if err != nil || preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStageColorMapping { + t.Fatalf("pending mapping unexpectedly became purchase-ready: %+v err=%v", preview, err) + } +} + +func TestBatchSpecMatchReportsProviderFailureWithoutWriting(t *testing.T) { + service, f := unresolvedBatchSpecFixture(t) + service.Matcher = &batchSpecMatcher{errs: []error{&aimatching.Error{Code: aimatching.CodeProviderUnavailable, Message: "provider down"}}} + + response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}}) + if err != nil || response.FailedCount != 1 || response.Items[0].Status != BatchSpecMatchFailed { + t.Fatalf("unexpected provider failure result: %+v err=%v", response, err) + } + if mapping := savedColorMapping(t, service, f.shopee.ID); mapping == nil || mapping.PDDValue != "旧白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed { + t.Fatalf("provider failure changed the existing mapping: %+v", mapping) + } +} + +func TestBatchSpecMatchErrorMessageDoesNotExposeProviderCause(t *testing.T) { + err := &aimatching.Error{Code: aimatching.CodeProviderUnavailable, Message: "safe", Cause: errors.New("secret upstream detail")} + if got := batchSpecMatchErrorMessage(err); got != "AI 匹配服务暂时不可用,请稍后重试" { + t.Fatalf("unsafe or unexpected message: %q", got) + } +} diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 43bb6cb..e04e56c 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -142,6 +142,26 @@ func (h Handler) AdminBatchPreview(c *gin.Context) { writeAdminData(c, response) } +func (h Handler) AdminBatchSpecMatch(c *gin.Context) { + if !allowedOperator(c) { + return + } + var req BatchSpecMatchRequest + if !decode(c, &req) { + return + } + service, ok := h.service(c) + if !ok { + return + } + response, err := service.BatchSpecMatch(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeAdminData(c, response) +} + func (h Handler) AdminBatchCreate(c *gin.Context) { if !allowedOperator(c) { return diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index e688372..98a3307 100644 --- a/server/app/goauto/purchase/router.go +++ b/server/app/goauto/purchase/router.go @@ -28,6 +28,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) admin.GET("", h.AdminList) admin.POST("/batch-preview", h.AdminBatchPreview) + admin.POST("/batch-spec-match", h.AdminBatchSpecMatch) admin.POST("/batch", h.AdminBatchCreate) admin.POST("/batch-retry", h.AdminBatchRetry) admin.POST("/stock", h.AdminCreateStock) diff --git a/server/app/goauto/shopeeproduct/batch_resolved_mapping.go b/server/app/goauto/shopeeproduct/batch_resolved_mapping.go new file mode 100644 index 0000000..1fb7d08 --- /dev/null +++ b/server/app/goauto/shopeeproduct/batch_resolved_mapping.go @@ -0,0 +1,75 @@ +package shopeeproduct + +import ( + "context" + "strings" +) + +// ResolvedMappingItem is one mapping produced by the narrowly scoped SYB +// batch-match entry point. Confirmed writes are intentionally restricted to +// auditable AI decisions; the general SetMapping path remains pending-first. +type ResolvedMappingItem struct { + Dimension string + ValueName string + PDDValue string + Source string + Status string + Confidence *float64 + Reason string +} + +// ApplyResolvedMappings atomically applies the color/size mappings needed by +// one SYB detail row. It is independent from SetMapping so #188's explicit +// high-confidence exception cannot change existing callers' pending semantics. +func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, minimumConfidence float64, items []ResolvedMappingItem) (SaveResponse, error) { + if len(items) == 0 || len(items) > 2 { + return SaveResponse{}, invalidRequest("必须包含 1 至 2 个待写入规格映射") + } + if minimumConfidence < 0 || minimumConfidence > 1 { + return SaveResponse{}, invalidRequest("自动确认阈值无效") + } + seen := make(map[string]bool, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Dimension) + "\x00" + strings.TrimSpace(item.ValueName) + if seen[key] { + return SaveResponse{}, invalidRequest("待写入规格映射重复") + } + seen[key] = true + if item.Source != MappingSourceExactMatch && item.Source != MappingSourceAIMatch { + return SaveResponse{}, invalidRequest("批量匹配来源无效") + } + if item.Status != MappingStatusConfirmed || item.Source != MappingSourceAIMatch || item.Confidence == nil || *item.Confidence < minimumConfidence || strings.TrimSpace(item.Reason) == "" { + return SaveResponse{}, invalidRequest("自动确认仅允许带置信度和理由的 AI 匹配") + } + if err := service.validatePDDMappingTarget(ctx, id, item.Dimension, item.ValueName, strings.TrimSpace(item.PDDValue)); err != nil { + return SaveResponse{}, err + } + } + + return service.mutateSpecs(ctx, id, requestID, func(specs []SpecDimension) ([]SpecDimension, error) { + for _, item := range items { + updated := false + for di, dimension := range specs { + if dimension.Name != item.Dimension { + continue + } + for vi, value := range dimension.Values { + if value.Name != item.ValueName { + continue + } + specs[di].Values[vi].Mapping = &Mapping{ + PDDValue: strings.TrimSpace(item.PDDValue), Source: item.Source, Status: item.Status, + Confidence: item.Confidence, Reason: strings.TrimSpace(item.Reason), + } + updated = true + break + } + break + } + if !updated { + return nil, valueNotFound() + } + } + return specs, nil + }) +} diff --git a/web/src/api/goauto/purchase-tasks.js b/web/src/api/goauto/purchase-tasks.js index 6ea52ef..9298699 100644 --- a/web/src/api/goauto/purchase-tasks.js +++ b/web/src/api/goauto/purchase-tasks.js @@ -3,6 +3,7 @@ import request from '@/utils/request' export function listPurchaseTasks(params) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'get', params }) } export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) } export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) } +export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) } export function createPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch', method: 'post', data, timeout: 60000 }) } export function retryPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-retry', method: 'post', data, timeout: 60000 }) } export function createStockPurchaseTask(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/stock', method: 'post', data, ...options }) } diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index e994386..df3c24e 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -12,9 +12,9 @@ - 查询重置创建采购({{ purchaseCandidates.length }})创建 PDD 采集({{ collectionCandidates.length }}) + 查询重置AI 匹配{{ aiMatchCandidates.length }}创建采购{{ purchaseCandidates.length }}创建采集{{ collectionCandidates.length }} - + @@ -90,6 +90,22 @@ + +
+ 自动确认 {{ specMatchResult.autoConfirmedCount }} 条 + 待人工 {{ specMatchResult.pendingCount }} 条 + 失败 {{ specMatchResult.failedCount }} 条 + 跳过 {{ specMatchResult.skippedCount }} 条 +
+ + + + + + + +
+
@@ -151,7 +167,7 @@ import { Search, RefreshLeft } from '@element-plus/icons-vue' import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products' import { listSybShops } from '@/api/goauto/syb-shops' import { listDevices } from '@/api/goauto/devices' -import { createPurchaseTasksBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks' +import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks' import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks' import { listCollectionRules } from '@/api/goauto/collection-rules' import { createRequestId } from '@/utils/request-id' @@ -165,11 +181,12 @@ export default { data() { return { loading: false, products: [], selectedProducts: [], total: 0, - purchaseReadiness: {}, purchaseReadinessLoading: false, loadGeneration: 0, + purchaseReadiness: {}, purchaseReadinessLoading: false, specMatchLoading: false, loadGeneration: 0, purchaseDevices: [], shopOptions: [], shopOptionsLoaded: false, shopOptionsPromise: null, purchaseDialog: { open: false, loading: false, saving: false, ids: [], selectedCount: 0, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 }, purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 }, + specMatchResult: { open: false, items: [], selectedIDs: [], autoConfirmedCount: 0, pendingCount: 0, failedCount: 0, skippedCount: 0 }, collectionBatch: this.emptyCollectionBatch(), collectionBatchData: { ruleId: null, deviceId: null }, collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] }, @@ -183,6 +200,7 @@ export default { 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: '待人工核对' }] }, + aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).processStage === 'color_mapping') }, purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) }, collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) }, collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] }, @@ -248,13 +266,16 @@ 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.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 }, - rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && (this.isPurchaseCandidate(row) || this.isCollectionCandidate(row)) }, + isAIMatchCandidate(row) { return this.purchaseReady(row).processStage === 'color_mapping' }, + isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) }, + rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(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)}` }, 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_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' }, - async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration) { + async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) { if (generation !== this.loadGeneration) return + const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id)) this.purchaseReadiness = {} if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return } this.purchaseReadinessLoading = true @@ -263,11 +284,9 @@ export default { if (generation !== this.loadGeneration) return this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item])) await this.$nextTick() - const valid = this.selectedProducts.filter(row => this.rowSelectable(row)) - if (valid.length !== this.selectedProducts.length) { - this.$refs.productTable?.clearSelection() - valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true)) - } + const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row)) + this.$refs.productTable?.clearSelection() + valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true)) } catch { if (generation !== this.loadGeneration) return this.purchaseReadiness = Object.fromEntries(ids.map(id => [id, { sybProductId: id, eligible: false, reason: '采购准备检查失败,请点击刷新重试', processStage: 'manual_action', processStageLabel: '待人工处理', processStageReason: '采购准备检查失败,请点击刷新重试', processNextAction: 'refresh' }])) @@ -298,6 +317,25 @@ export default { if (readiness.nextAction === 'refresh') return this.load() }, + async runBatchSpecMatch() { + if (!this.aiMatchCandidates.length || this.specMatchLoading) return + const selectedIDs = this.selectedProducts.map(row => row.id) + this.specMatchLoading = true + try { + const response = await matchPurchaseSpecsBatch({ sybProductIds: selectedIDs }) + this.specMatchResult = { open: true, selectedIDs, ...response.data } + } finally { + this.specMatchLoading = false + } + }, + async refreshAfterSpecMatch() { + await this.loadPurchaseReadiness(this.products.map(item => item.id), {}, this.loadGeneration, new Set(this.specMatchResult.selectedIDs)) + }, + specMatchStatusMeta(status) { + return { auto_confirmed: { label: '已确认', type: 'success' }, pending: { label: '待人工', type: 'warning' }, failed: { label: '失败', type: 'danger' }, skipped: { label: '已跳过', type: 'info' }}[status] || { label: status || '未知', type: 'info' } + }, + formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' }, + // ---------------- 批量创建采购任务 ---------------- async openSinglePurchase(row) { this.$refs.productTable?.clearSelection() @@ -427,6 +465,8 @@ export default { .shop-option-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .toolbar-actions :deep(.el-form-item__content){gap:8px} .toolbar-actions :deep(.el-button + .el-button){margin-left:0} +.action-count{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;margin-left:6px;padding:0 5px;border-radius:9px;background:#eef2f7;color:#606266;font-size:11px;font-variant-numeric:tabular-nums;line-height:18px} +.action-count-primary{background:rgba(255,255,255,.2);color:inherit} .selection-count{display:inline-flex;align-items:center;min-height:32px;white-space:nowrap;color:#606266} .selection-count strong{margin:0 4px;font-variant-numeric:tabular-nums;color:#1e40af} .primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399} @@ -437,6 +477,7 @@ export default { .notice{margin-bottom:16px} .compact-notice{margin-bottom:12px} .purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.spec-match-summary{gap:8px 24px} .purchase-note{margin:0 0 16px} .drawer-body{padding:0 4px 24px} .split{display:grid;grid-template-columns:1fr 1fr;gap:24px} diff --git a/web/tests/e2e/syb-product-layout.spec.ts b/web/tests/e2e/syb-product-layout.spec.ts index 4b8cc9f..e4bead4 100644 --- a/web/tests/e2e/syb-product-layout.spec.ts +++ b/web/tests/e2e/syb-product-layout.spec.ts @@ -37,12 +37,12 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context const selectionCount = await page.getByText('已选择 0 条', { exact: true }).boundingBox() const queryButton = await page.getByRole('button', { name: '查询', exact: true }).boundingBox() const reset = await page.getByRole('button', { name: '重置' }).boundingBox() - const purchase = await page.getByRole('button', { name: '创建采购(0)', exact: true }).boundingBox() + const purchase = await page.getByRole('button', { name: /创建采购\s*0/ }).boundingBox() expect(selectionCount).not.toBeNull(); expect(queryButton).not.toBeNull(); expect(reset).not.toBeNull(); expect(purchase).not.toBeNull() expect(Math.abs((selectionCount?.y || 0) - (queryButton?.y || 0))).toBeLessThan(2) expect(Math.abs((reset?.y || 0) - (purchase?.y || 0))).toBeLessThan(2) expect(purchase?.x || 0).toBeGreaterThan((reset?.x || 0) + (reset?.width || 0)) - await expect(page.getByRole('button', { name: '创建采购(0)', exact: true })).toBeDisabled() + await expect(page.getByRole('button', { name: /创建采购\s*0/ })).toBeDisabled() const shopControl = page.getByPlaceholder('选择或输入店铺') const shopInput = await shopControl.boundingBox() const orderInput = await page.getByPlaceholder('每行一个订单号,最多 100 个').boundingBox() @@ -56,8 +56,8 @@ 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() + await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled() + await expect(page.getByRole('button', { name: /创建采集\s*1/ })).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) @@ -78,6 +78,55 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context await expect.poll(() => listURLs.at(-1)?.searchParams.get('processStage')).toBe('pdd_pending') }) +test('批量 AI 匹配展示混合结果并在关闭后刷新采购候选', async({ page, context }) => { + await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) + let matched = false + let matchBody: { sybProductIds: number[] } | null = null + 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/menurole')) return route.fulfill({ json: { code: 200, data: sybProductRoute }}) + 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: 81, orderCode: 'MATCH-81', shopeeItemId: '1081', shopeeProductId: 181, productTitle: '高置信度候选', quantity: 1, parseStatus: 'success' }, + { id: 82, orderCode: 'MATCH-82', shopeeItemId: '1082', shopeeProductId: 182, productTitle: '低置信度候选', quantity: 1, parseStatus: 'success' }, + { id: 83, orderCode: 'READY-83', shopeeItemId: '1083', shopeeProductId: 183, productTitle: '已就绪候选', quantity: 1, parseStatus: 'success' } + ], total: 3, page: 1, pageSize: 20 }}}) + if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-spec-match')) { + matchBody = route.request().postDataJSON() + matched = true + return route.fulfill({ json: { code: 200, data: { + items: [ + { sybProductId: 81, orderCode: 'MATCH-81', status: 'auto_confirmed', source: 'ai_match', confidence: 0.96, reason: '唯一可靠候选' }, + { sybProductId: 82, orderCode: 'MATCH-82', status: 'pending', source: 'ai_match', confidence: 0.61, reason: '未达到自动确认阈值' }, + { sybProductId: 83, orderCode: 'READY-83', status: 'skipped', reason: '当前不是颜色待匹配阶段,无需处理' } + ], autoConfirmedCount: 1, pendingCount: 1, failedCount: 0, skippedCount: 1 + }}}) + } + if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) { + const stage = (id: number) => id === 83 || (id === 81 && matched) ? 'purchase_ready' : 'color_mapping' + const items = [81, 82, 83].map(id => ({ sybProductId: id, pddProductId: 200 + id, eligible: stage(id) === 'purchase_ready', collectionEligible: false, processStage: stage(id), processStageLabel: stage(id) === 'purchase_ready' ? '可创建采购' : '颜色待匹配', processStageReason: '' })) + return route.fulfill({ json: { code: 200, data: { items, eligibleCount: items.filter(item => item.eligible).length, skippedCount: items.filter(item => !item.eligible).length }}}) + } + return route.fulfill({ json: { code: 200, data: [] }}) + }) + + await page.goto('/#/syb-products') + const rows = page.locator('.el-table__body-wrapper tbody tr') + for (let index = 0; index < 3; index += 1) await rows.nth(index).locator('.el-checkbox').click() + await expect(page.getByRole('button', { name: /AI 匹配\s*2/ })).toBeEnabled() + await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled() + await page.getByRole('button', { name: /AI 匹配\s*2/ }).click() + await expect(page.getByRole('dialog', { name: 'AI 匹配结果' })).toBeVisible() + await expect(page.getByText('自动确认 1 条', { exact: true })).toBeVisible() + await expect(page.getByText('待人工 1 条', { exact: true })).toBeVisible() + await expect(page.getByText('96%', { exact: true })).toBeVisible() + expect(matchBody?.sybProductIds).toEqual([81, 82, 83]) + await page.getByRole('button', { name: '关闭', exact: true }).click() + await expect(page.getByRole('button', { name: /创建采购\s*2/ })).toBeEnabled() + await expect(page.getByRole('button', { name: /AI 匹配\s*1/ })).toBeEnabled() +}) + test('店铺选项加载失败时仍可输入历史店铺查询', async({ page, context }) => { await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]) const listURLs: URL[] = [] @@ -209,10 +258,10 @@ test('混选后采购与采集分别提交候选子集且采集按 PDD 去重', await expect(rows).toHaveCount(3) for (let index = 0; index < 3; index += 1) await rows.nth(index).locator('.el-checkbox').click() await expect(page.getByText('已选择 3 条', { exact: true })).toBeVisible() - await expect(page.getByRole('button', { name: '创建采购(1)', exact: true })).toBeEnabled() - await expect(page.getByRole('button', { name: '创建 PDD 采集(1)', exact: true })).toBeEnabled() + await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled() + await expect(page.getByRole('button', { name: /创建采集\s*1/ })).toBeEnabled() - await page.getByRole('button', { name: '创建采购(1)', exact: true }).click() + await page.getByRole('button', { name: /创建采购\s*1/ }).click() await expect(page.getByText('已选择 3 条 SYB 明细', { exact: true })).toBeVisible() await page.getByRole('button', { name: '创建 1 个采购任务', exact: true }).click() await expect.poll(() => purchaseBodies.length).toBe(1) @@ -220,7 +269,7 @@ test('混选后采购与采集分别提交候选子集且采集按 PDD 去重', await page.getByRole('button', { name: '返回 SYB 商品', exact: true }).click() for (let index = 0; index < 3; index += 1) await rows.nth(index).locator('.el-checkbox').click() - await page.getByRole('button', { name: '创建 PDD 采集(1)', exact: true }).click() + await page.getByRole('button', { name: /创建采集\s*1/ }).click() await expect(page.getByText('其中 2 条适用于采集', { exact: true })).toBeVisible() await expect(page.getByText('去重后 1 个 PDD 商品', { exact: true })).toBeVisible() await page.getByRole('combobox', { name: '*采集规则' }).click() @@ -252,8 +301,8 @@ test('去重后超过 100 个 PDD 商品时给出提示且不调用批量接口' await page.goto('/#/syb-products') await expect(page.locator('.el-table__body-wrapper tbody tr')).toHaveCount(101) await page.locator('.el-table__header-wrapper .el-checkbox').first().click() - await expect(page.getByRole('button', { name: '创建 PDD 采集(101)', exact: true })).toBeEnabled() - await page.getByRole('button', { name: '创建 PDD 采集(101)', exact: true }).click() + await expect(page.getByRole('button', { name: /创建采集\s*101/ })).toBeEnabled() + await page.getByRole('button', { name: /创建采集\s*101/ }).click() await expect(page.getByText('一次最多创建 100 个去重后的 PDD 商品采集任务', { exact: true })).toBeVisible() await expect(page.getByRole('dialog', { name: '批量创建 PDD 采集任务' })).toHaveCount(0) expect(collectionCalls).toHaveLength(0)