feat: batch AI match SYB purchase specs (#188)
This commit is contained in:
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -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 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 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`,管理员和采购员可操作 |
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 "规格匹配失败,请稍后重试"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 }) }
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购({{ purchaseCandidates.length }})</el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建 PDD 采集({{ collectionCandidates.length }})</el-button></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button v-if="canPurchase" :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="canPurchase" title="可同时选择可创建采购与 PDD 待采集明细;两个批量动作只处理各自适用项,表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-alert v-if="canPurchase" title="可同时选择待 AI 匹配、可创建采购与 PDD 待采集明细;三个批量动作只处理各自适用项,表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
||||
<el-table-column v-if="canPurchase" type="selection" width="48" :selectable="rowSelectable" />
|
||||
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
|
||||
@@ -90,6 +90,22 @@
|
||||
<template #footer><template v-if="collectionBatch.step === 'confirm'"><el-button @click="collectionBatch.open = false">取消</el-button><el-button type="primary" :loading="collectionBatch.saving" :disabled="collectionBatch.products.length === 0" @click="submitCollectionBatch">创建 {{ collectionBatch.products.length }} 个采集任务</el-button></template><el-button v-else type="primary" @click="collectionBatch.open = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="specMatchResult.open" title="AI 匹配结果" width="820px" :close-on-click-modal="false" @closed="refreshAfterSpecMatch">
|
||||
<div class="purchase-summary spec-match-summary" aria-live="polite">
|
||||
<span class="success-text">自动确认 <strong>{{ specMatchResult.autoConfirmedCount }}</strong> 条</span>
|
||||
<span>待人工 <strong>{{ specMatchResult.pendingCount }}</strong> 条</span>
|
||||
<span v-if="specMatchResult.failedCount" class="danger-text">失败 <strong>{{ specMatchResult.failedCount }}</strong> 条</span>
|
||||
<span v-if="specMatchResult.skippedCount">跳过 <strong>{{ specMatchResult.skippedCount }}</strong> 条</span>
|
||||
</div>
|
||||
<el-table :data="specMatchResult.items" border size="small" max-height="420" empty-text="没有匹配结果">
|
||||
<el-table-column label="订单号" min-width="150"><template #default="{ row }">{{ row.orderCode || `ID ${row.sybProductId}` }}</template></el-table-column>
|
||||
<el-table-column label="结果" width="110"><template #default="{ row }"><el-tag :type="specMatchStatusMeta(row.status).type" effect="plain">{{ specMatchStatusMeta(row.status).label }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="置信度" width="90"><template #default="{ row }">{{ formatConfidence(row.confidence) }}</template></el-table-column>
|
||||
<el-table-column label="说明" min-width="300"><template #default="{ row }">{{ row.reason }}</template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button type="primary" @click="specMatchResult.open = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情 -->
|
||||
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user