fix: tighten batch AI matching eligibility (#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: 084dae8190b765758cc77a59732215bd8a9ca6fe
|
||||
synchronized_at: 2026-09-01T04:05:25Z
|
||||
wiki_revision: b9d13a3576f331091687bca1d6407f9d41901fad
|
||||
synchronized_at: 2026-09-01T06:41:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -31,7 +31,7 @@ synchronized_at: 2026-09-01T04:05:25Z
|
||||
- 关联 PDD 商品时校验目标存在且非 `disabled`;不提供手工输入商品 ID 的入口,只能通过搜索选择。
|
||||
- 参考售价 `sale_price_cent` 为整数分,`currency` 为 ISO 4217 代码;币种取系统配置默认值,不逐商品选择,缺省回退为 `TWD`。
|
||||
- 规格值来源分 `import`(SYB 导入,不可人工删除,只能清除映射)与 `manual`(人工添加,可删除);导入与人工值按「维度 + 名称」合并,不重复创建。
|
||||
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称完全相等自动预填)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配是独立例外:只有 `ai_match` 置信度存在且达到服务端阈值、候选与原因完整时才可直接持久化为 `confirmed`,低置信度或异常不得改写现有映射。
|
||||
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称完全相等自动预填)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配是独立例外:只有 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,并且最近一次成功或部分成功采集提供完整可售 SKU 组合证据时才进入批量 AI 匹配;`parse_status=uncertain` 必须先人工修正。只有 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`;低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
|
||||
- 颜色映射只能从关联 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: 44f936706dfd36b0281215a239b790bbe50aaf77
|
||||
synchronized_at: 2026-09-01T04:09:50Z
|
||||
wiki_revision: 5afa14ea875e38dc08718a19d4646147a56d829f
|
||||
synchronized_at: 2026-09-01T06:42:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -501,8 +501,8 @@ POST /api/agent/v1/tasks/{taskId}/fail
|
||||
| `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/stock` | 创建备货采购;`requestId`、`executionMode`、`pddProductId`、可选 `deviceId` / `pddAccountId`、`color`、可选 `size`、`quantity`、`minUnitPriceCent`、`maxUnitPriceCent`。服务端使用内置规则并从所选颜色归档派生参考价;相同 `requestId` 幂等返回原任务 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 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-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与 AI 匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务 |
|
||||
| `POST` | `/api/admin/v1/purchase-tasks/batch-spec-match` | `sybProductIds`(1~100);仅处理服务端预检 `aiMatchEligible=true` 的明细,其他明细按具体禁用原因返回 `skipped`,并逐条返回 `auto_confirmed` / `pending` / `failed` / `skipped`。资格要求 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,且最近一次成功或部分成功采集存在完整可售 SKU 组合证据;`parse_status=uncertain` 不进入自动确认。只有 `ai_match` 置信度达到 `autoConfirmMinConfidence`、理由非空、返回值属于当前候选且颜色+尺码命中同一个可售 SKU 组合时,才通过独立路径把该明细所需映射写为 `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`,管理员和采购员可操作 |
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/productspec"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
)
|
||||
|
||||
type pddSKUCombination struct {
|
||||
Color string
|
||||
Size string
|
||||
}
|
||||
|
||||
type aiMatchQualification struct {
|
||||
Eligible bool
|
||||
DisabledReason string
|
||||
Request aimatching.MatchRequest
|
||||
MappedColor string
|
||||
MappedSize string
|
||||
}
|
||||
|
||||
type skuCombinationRow struct {
|
||||
PDDProductID uint64 `gorm:"column:pdd_product_id"`
|
||||
SKUID uint64 `gorm:"column:sku_id"`
|
||||
DimensionKey string `gorm:"column:dimension_key"`
|
||||
Value string `gorm:"column:value"`
|
||||
}
|
||||
|
||||
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
|
||||
if len(pddIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var rows []skuCombinationRow
|
||||
skuIDColumn := s.DB.NamingStrategy.ColumnName("", "SKUID")
|
||||
err := s.DB.WithContext(ctx).Table("collection_task AS ct").
|
||||
Select("ct.pdd_product_id, cs.id AS sku_id, cd.key AS dimension_key, cdv.value").
|
||||
Joins("JOIN collection_sku cs ON cs.task_id = ct.id").
|
||||
Joins(fmt.Sprintf("JOIN collection_sku_value csv ON csv.%s = cs.id", skuIDColumn)).
|
||||
Joins("JOIN collection_dimension_value cdv ON cdv.id = csv.dimension_value_id").
|
||||
Joins("JOIN collection_dimension cd ON cd.id = cdv.dimension_id").
|
||||
Where("ct.pdd_product_id IN ?", pddIDs).
|
||||
Where("ct.status IN ?", []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}).
|
||||
Where("ct.deleted_at IS NULL").
|
||||
Where("ct.id = (SELECT MAX(ct2.id) FROM collection_task ct2 WHERE ct2.pdd_product_id = ct.pdd_product_id AND ct2.status IN ? AND ct2.deleted_at IS NULL)", []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}).
|
||||
Where("cs.available = ? AND cs.complete = ?", true, true).
|
||||
Order("ct.pdd_product_id, cs.id, cd.sort_order").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type groupedCombination struct {
|
||||
PDDProductID uint64
|
||||
Combination pddSKUCombination
|
||||
}
|
||||
grouped := make(map[uint64]*groupedCombination)
|
||||
for _, row := range rows {
|
||||
entry := grouped[row.SKUID]
|
||||
if entry == nil {
|
||||
entry = &groupedCombination{PDDProductID: row.PDDProductID}
|
||||
grouped[row.SKUID] = entry
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(row.DimensionKey)) {
|
||||
case shopeeproduct.RoleColor:
|
||||
entry.Combination.Color = strings.TrimSpace(row.Value)
|
||||
case shopeeproduct.RoleSize:
|
||||
entry.Combination.Size = strings.TrimSpace(row.Value)
|
||||
}
|
||||
}
|
||||
for _, entry := range grouped {
|
||||
dataset.skuCombinationsByPDD[entry.PDDProductID] = append(dataset.skuCombinationsByPDD[entry.PDDProductID], entry.Combination)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMatchQualification {
|
||||
disabled := func(reason string) aiMatchQualification { return aiMatchQualification{DisabledReason: reason} }
|
||||
if task, found := dataset.latestTaskBySYB[id]; found {
|
||||
switch task.Status {
|
||||
case models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending, models.PurchaseTaskStatusRunning,
|
||||
models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderResultUnknown, models.PurchaseTaskStatusOrderCreated:
|
||||
return disabled("该明细已有采购任务或订单结果,不能重复匹配")
|
||||
}
|
||||
}
|
||||
syb, found := dataset.sybByID[id]
|
||||
if !found {
|
||||
return disabled("SYB 商品不存在或已删除")
|
||||
}
|
||||
if syb.ParseStatus != models.SYBParseStatusSuccess {
|
||||
if syb.ParseStatus == models.SYBParseStatusUncertain {
|
||||
return disabled("采购规格解析存疑,请先人工修正并确认")
|
||||
}
|
||||
return disabled("采购规格解析失败,请先重新解析或人工修正")
|
||||
}
|
||||
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
|
||||
return disabled("未解析出需要采购的颜色或尺码")
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
return disabled("尚未关联蝦皮商品")
|
||||
}
|
||||
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
if !found {
|
||||
return disabled("关联的蝦皮商品不存在")
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return disabled("尚未关联 PDD 商品")
|
||||
}
|
||||
pdd, found := dataset.pddByID[*shopee.PDDProductID]
|
||||
if !found || pdd.Status != "active" {
|
||||
return disabled("关联的 PDD 商品尚未采集完成或已停用")
|
||||
}
|
||||
if hasSelectableOtherDimension(pdd.SpecsJSON) {
|
||||
return disabled("PDD 商品包含颜色、尺码之外的可选规格,请人工处理")
|
||||
}
|
||||
candidates, usable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
if !usable {
|
||||
return disabled("关联的 PDD 商品没有完整可选规格")
|
||||
}
|
||||
mappedColor, mappedSize, _ := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
if mappedColor != "" && !containsValue(candidates.Colors, mappedColor) {
|
||||
mappedColor = ""
|
||||
}
|
||||
if mappedSize != "" && !containsValue(candidates.Sizes, mappedSize) {
|
||||
mappedSize = ""
|
||||
}
|
||||
if mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) && validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
return disabled("已有有效确认映射,无需 AI 匹配")
|
||||
}
|
||||
request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if mappedColor == "" {
|
||||
request.TargetColor = syb.TargetColor
|
||||
if !shopeeHasTarget(shopee.SpecsJSON, shopeeproduct.RoleColor, syb.TargetColor) {
|
||||
return disabled("蝦皮商品中未找到目标颜色,请先修正商品规格")
|
||||
}
|
||||
}
|
||||
if mappedSize == "" {
|
||||
request.TargetSize = syb.TargetSize
|
||||
if !shopeeHasTarget(shopee.SpecsJSON, shopeeproduct.RoleSize, syb.TargetSize) {
|
||||
return disabled("蝦皮商品中未找到目标尺码,请先修正商品规格")
|
||||
}
|
||||
}
|
||||
if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
color, size := mappedColor, mappedSize
|
||||
if request.TargetColor != "" {
|
||||
color = matched.MappedColor
|
||||
}
|
||||
if request.TargetSize != "" {
|
||||
size = matched.MappedSize
|
||||
}
|
||||
if validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, color, size) {
|
||||
return disabled("已有唯一确定性匹配结果,无需 AI 匹配")
|
||||
}
|
||||
}
|
||||
if len(dataset.skuCombinationsByPDD[pdd.ID]) == 0 {
|
||||
return disabled("缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集")
|
||||
}
|
||||
return aiMatchQualification{Eligible: true, Request: request, MappedColor: mappedColor, MappedSize: mappedSize}
|
||||
}
|
||||
|
||||
func validSKUCombination(combinations []pddSKUCombination, targetColor, targetSize, mappedColor, mappedSize string) bool {
|
||||
if strings.TrimSpace(targetColor) != "" && strings.TrimSpace(mappedColor) == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(targetSize) != "" && strings.TrimSpace(mappedSize) == "" {
|
||||
return false
|
||||
}
|
||||
for _, combination := range combinations {
|
||||
if (strings.TrimSpace(targetColor) == "" || combination.Color == mappedColor) && (strings.TrimSpace(targetSize) == "" || combination.Size == mappedSize) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shopeeHasTarget(raw, role, target string) bool {
|
||||
if strings.TrimSpace(target) == "" {
|
||||
return true
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != role {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Name == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSelectableOtherDimension(raw string) bool {
|
||||
var dimensions []productspec.Dimension
|
||||
if json.Unmarshal([]byte(raw), &dimensions) != nil {
|
||||
return false
|
||||
}
|
||||
for _, dimension := range dimensions {
|
||||
if dimension.Role != "other" {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Selectable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -60,6 +60,8 @@ type BatchPreviewItem struct {
|
||||
ProcessNextAction string `json:"processNextAction,omitempty"`
|
||||
CollectionEligible bool `json:"collectionEligible"`
|
||||
CollectionDisabledReason string `json:"collectionDisabledReason,omitempty"`
|
||||
AIMatchEligible bool `json:"aiMatchEligible"`
|
||||
AIMatchDisabledReason string `json:"aiMatchDisabledReason,omitempty"`
|
||||
}
|
||||
|
||||
type BatchPreviewResponse struct {
|
||||
@@ -105,6 +107,9 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
|
||||
for _, id := range ids {
|
||||
item := s.previewFromDataset(ctx, id, dataset, false, guard)
|
||||
item.applyCollectionEligibility(id, dataset)
|
||||
qualification := aiMatchQualificationForDataset(id, dataset)
|
||||
item.AIMatchEligible = qualification.Eligible
|
||||
item.AIMatchDisabledReason = qualification.DisabledReason
|
||||
if ruleReason != "" && item.Eligible {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = purchaserule.CodeCurrentRuleMissing, ruleReason, "refresh"
|
||||
@@ -130,6 +135,7 @@ type batchPreviewDataset struct {
|
||||
latestCollectionByPDD map[uint64]models.CollectionTask
|
||||
activeCollectionByPDD map[uint64]uint64
|
||||
collectionRuleAvailable bool
|
||||
skuCombinationsByPDD map[uint64][]pddSKUCombination
|
||||
}
|
||||
|
||||
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
|
||||
@@ -144,6 +150,7 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
|
||||
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
||||
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
|
||||
activeCollectionByPDD: make(map[uint64]uint64),
|
||||
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
|
||||
}
|
||||
var sybProducts []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
|
||||
@@ -176,6 +183,9 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
|
||||
for _, pdd := range pddProducts {
|
||||
dataset.pddByID[pdd.ID] = pdd
|
||||
}
|
||||
if err := s.loadLatestSKUCombinations(ctx, pddIDs, &dataset); err != nil {
|
||||
return dataset, err
|
||||
}
|
||||
var activeCollectionTasks []models.CollectionTask
|
||||
if err := s.DB.WithContext(ctx).
|
||||
Where("pdd_product_id IN ? AND status IN ?", pddIDs, []string{models.TaskStatusPending, models.TaskStatusRunning}).
|
||||
@@ -375,8 +385,13 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
}
|
||||
item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID
|
||||
item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity
|
||||
if syb.ParseStatus == models.SYBParseStatusFailed {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "解析失败,请先重新解析或人工修正", "reparse"
|
||||
if syb.ParseStatus != models.SYBParseStatusSuccess {
|
||||
item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse"
|
||||
if syb.ParseStatus == models.SYBParseStatusUncertain {
|
||||
item.Reason = "采购规格解析存疑,请先人工修正并确认"
|
||||
} else {
|
||||
item.Reason = "解析失败,请先重新解析或人工修正"
|
||||
}
|
||||
return item
|
||||
}
|
||||
if syb.ShopeeProductID == nil {
|
||||
@@ -411,18 +426,23 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
return item
|
||||
}
|
||||
if source == "unresolved" {
|
||||
if archiveUsable {
|
||||
request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if allowAI {
|
||||
matched, matchErr := s.matcher().Resolve(ctx, request)
|
||||
if matchErr != nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr))
|
||||
return item
|
||||
}
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
} else if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
if !archiveUsable {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "关联的 PDD 商品没有完整可选规格", "open_pdd"
|
||||
return item
|
||||
}
|
||||
request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if allowAI {
|
||||
matched, matchErr := s.matcher().Resolve(ctx, request)
|
||||
if matchErr != nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr))
|
||||
return item
|
||||
}
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
} else if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
} else {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格映射不完整,需要 AI 匹配或人工确认", "open_mapping"
|
||||
return item
|
||||
}
|
||||
}
|
||||
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/purchaserule"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -51,10 +50,6 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
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)
|
||||
@@ -67,13 +62,9 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
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, "当前不是颜色待匹配阶段,无需处理"
|
||||
qualification := aiMatchQualificationForDataset(id, dataset)
|
||||
if !qualification.Eligible {
|
||||
item.Status, item.Reason = BatchSpecMatchSkipped, qualification.DisabledReason
|
||||
response.SkippedCount++
|
||||
response.Items = append(response.Items, item)
|
||||
continue
|
||||
@@ -81,27 +72,7 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
|
||||
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
|
||||
}
|
||||
request := qualification.Request
|
||||
matched, matchErr := s.matcher().Resolve(ctx, request)
|
||||
if matchErr != nil {
|
||||
item.Reason = batchSpecMatchErrorMessage(matchErr)
|
||||
@@ -120,6 +91,19 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
response.Items = append(response.Items, item)
|
||||
continue
|
||||
}
|
||||
mappedColor, mappedSize := qualification.MappedColor, qualification.MappedSize
|
||||
if request.TargetColor != "" {
|
||||
mappedColor = matched.MappedColor
|
||||
}
|
||||
if request.TargetSize != "" {
|
||||
mappedSize = matched.MappedSize
|
||||
}
|
||||
if !mappingTargetsValid(specCandidates{Colors: request.Colors, Sizes: request.Sizes}, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) || !validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
item.Status, item.Reason = BatchSpecMatchPending, "AI 结果不是当前可售的 PDD 规格组合,请人工确认"
|
||||
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()
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type batchSpecMatcher struct {
|
||||
@@ -22,6 +24,9 @@ func (matcher *batchSpecMatcher) Resolve(_ context.Context, _ aimatching.MatchRe
|
||||
if at < len(matcher.errs) && matcher.errs[at] != nil {
|
||||
return aimatching.MatchResult{}, matcher.errs[at]
|
||||
}
|
||||
if at >= len(matcher.results) {
|
||||
return aimatching.MatchResult{}, errors.New("unexpected matcher call")
|
||||
}
|
||||
return matcher.results[at], nil
|
||||
}
|
||||
|
||||
@@ -41,9 +46,57 @@ func unresolvedBatchSpecFixture(t *testing.T) (*Service, fixture) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.syb.TargetColor = "象牙白"
|
||||
seedAvailableSKUCombinations(t, db, f.pdd.ID, []pddSKUCombination{{Color: "米白色", Size: "XL"}, {Color: "白色", Size: "XL"}})
|
||||
return testService(db), f
|
||||
}
|
||||
|
||||
func seedAvailableSKUCombinations(t *testing.T, db *gorm.DB, pddID uint64, combinations []pddSKUCombination) {
|
||||
t.Helper()
|
||||
rule := models.CollectionRule{Name: "SKU 组合测试规则", ContentJSON: `{}`}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := models.CollectionTask{PDDProductID: &pddID, RuleID: rule.ID, Status: models.TaskStatusCompleted, URLSnapshot: "https://mobile.yangkeduo.com/goods.html?goods_id=test", GoodsIDSnapshot: "test", RuleSnapshot: `{}`}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
colorDimension := models.CollectionDimension{TaskID: task.ID, Key: "color", Name: "颜色", SortOrder: 0}
|
||||
sizeDimension := models.CollectionDimension{TaskID: task.ID, Key: "size", Name: "尺码", SortOrder: 1}
|
||||
if err := db.Create(&colorDimension).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&sizeDimension).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
colorValues := make(map[string]uint64)
|
||||
sizeValues := make(map[string]uint64)
|
||||
for index, combination := range combinations {
|
||||
if colorValues[combination.Color] == 0 {
|
||||
value := models.CollectionDimensionValue{DimensionID: colorDimension.ID, Value: combination.Color, SortOrder: index}
|
||||
if err := db.Create(&value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
colorValues[combination.Color] = value.ID
|
||||
}
|
||||
if sizeValues[combination.Size] == 0 {
|
||||
value := models.CollectionDimensionValue{DimensionID: sizeDimension.ID, Value: combination.Size, SortOrder: index}
|
||||
if err := db.Create(&value).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sizeValues[combination.Size] = value.ID
|
||||
}
|
||||
sku := models.CollectionSKU{TaskID: task.ID, SpecKey: combination.Color + "\x00" + combination.Size, PriceCent: 2000, Available: true, Complete: true}
|
||||
if err := db.Create(&sku).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, valueID := range []uint64{colorValues[combination.Color], sizeValues[combination.Size]} {
|
||||
if err := db.Create(&models.CollectionSKUValue{SKUID: sku.ID, DimensionValueID: valueID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func aiBatchResult(confidence float64) aimatching.MatchResult {
|
||||
request := aimatching.MatchRequest{TargetColor: "象牙白", Colors: []string{"米白色", "白色"}, Sizes: []string{"XL"}}
|
||||
result := aimatching.RecordedMatch(request, aimatching.SourceAI, "米白色", "", "颜色语义与候选唯一对应")
|
||||
@@ -93,6 +146,41 @@ func TestBatchSpecMatchAutoConfirmsHighConfidenceAndRefreshesReadiness(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
|
||||
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(preview.Items) != 1 {
|
||||
t.Fatalf("preview failed: %+v err=%v", preview, err)
|
||||
}
|
||||
item := preview.Items[0]
|
||||
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || item.Eligible || item.ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewRejectsUncertainParseAndMissingSKUCombination(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("parse_status", models.SYBParseStatusUncertain).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "采购规格解析存疑,请先人工修正并确认" || preview.Items[0].ProcessStage != ProcessStageManualAction {
|
||||
t.Fatalf("uncertain parse entered AI matching: %+v err=%v", preview, err)
|
||||
}
|
||||
|
||||
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("parse_status", models.SYBParseStatusSuccess).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.DB.Where("pdd_product_id = ?", f.pdd.ID).Delete(&models.CollectionTask{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err = service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集" {
|
||||
t.Fatalf("missing SKU combination entered AI matching: %+v err=%v", preview, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSpecMatchLeavesLowConfidenceForManualHandling(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}}
|
||||
@@ -124,6 +212,24 @@ func TestBatchSpecMatchReportsProviderFailureWithoutWriting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSpecMatchDoesNotConfirmInvalidSKUCombination(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
pddSpecs := `[{"name":"颜色","role":"color","values":[{"name":"米白色","selectable":true,"priceCent":2000},{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true},{"name":"L","selectable":true}]}]`
|
||||
if err := service.DB.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", pddSpecs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedAvailableSKUCombinations(t, service.DB, f.pdd.ID, []pddSKUCombination{{Color: "米白色", Size: "L"}, {Color: "白色", Size: "XL"}})
|
||||
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.95)}}
|
||||
|
||||
response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || response.PendingCount != 1 || response.AutoConfirmedCount != 0 || response.Items[0].Reason != "AI 结果不是当前可售的 PDD 规格组合,请人工确认" {
|
||||
t.Fatalf("invalid SKU combination was not held for manual review: %+v err=%v", response, err)
|
||||
}
|
||||
if mapping := savedColorMapping(t, service, f.shopee.ID); mapping == nil || mapping.PDDValue != "旧白色" {
|
||||
t.Fatalf("invalid combination changed the saved 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 匹配服务暂时不可用,请稍后重试" {
|
||||
|
||||
@@ -267,8 +267,8 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
||||
if queries != 7 {
|
||||
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
|
||||
}
|
||||
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible {
|
||||
t.Fatalf("unresolved row should remain selectable for full create-time matching: %+v", response)
|
||||
if len(response.Items) != 2 || response.Items[0].Eligible || response.Items[1].Eligible || response.Items[0].ReasonCode != CodeMappingRequired {
|
||||
t.Fatalf("unresolved rows must not be reported as purchase-ready: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,14 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
|
||||
return stage(ProcessStagePDDPending, "拼多多商品尚未采集完成", "open_pdd")
|
||||
}
|
||||
if preview.ReasonCode == CodeMappingRequired {
|
||||
return stage(ProcessStageColorMapping, "颜色匹配无效,请重新选择", "open_mapping")
|
||||
if preview.AIMatchEligible {
|
||||
return stage(ProcessStageColorMapping, "采购规格待 AI 匹配或人工确认", "open_mapping")
|
||||
}
|
||||
reason := strings.TrimSpace(preview.AIMatchDisabledReason)
|
||||
if reason == "" {
|
||||
reason = "规格匹配无效,请人工处理"
|
||||
}
|
||||
return stage(ProcessStageManualAction, reason, preview.NextAction)
|
||||
}
|
||||
if preview.Eligible {
|
||||
return stage(ProcessStagePurchaseReady, "当前条件可以创建采购任务", "")
|
||||
|
||||
@@ -53,7 +53,10 @@ func TestProcessStageDerivesAllUserFacingStages(t *testing.T) {
|
||||
pddID := uint64(3)
|
||||
d.latestCollectionByPDD[3] = models.CollectionTask{ID: 8, PDDProductID: &pddID, Status: models.TaskStatusFailed}
|
||||
}},
|
||||
{"颜色待匹配", ProcessStageColorMapping, "open_mapping", func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.ReasonCode = CodeMappingRequired }},
|
||||
{"颜色待匹配", ProcessStageColorMapping, "open_mapping", func(_ *batchPreviewDataset, p *BatchPreviewItem) {
|
||||
p.ReasonCode = CodeMappingRequired
|
||||
p.AIMatchEligible = true
|
||||
}},
|
||||
{"可创建采购", ProcessStagePurchaseReady, "", func(_ *batchPreviewDataset, p *BatchPreviewItem) { p.Eligible = true }},
|
||||
{"已创建任务", ProcessStageTaskCreated, "open_task", func(d *batchPreviewDataset, _ *BatchPreviewItem) {
|
||||
sybID := uint64(1)
|
||||
|
||||
@@ -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" :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-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><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="可同时选择待 AI 匹配、可创建采购与 PDD 待采集明细;三个批量动作只处理各自适用项,表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-alert v-if="canPurchase" title="AI 匹配只处理服务端确认已成功解析、已关联 PDD 且具备完整可售 SKU 组合的明细;三个批量动作只处理各自适用项,表头全选仅作用于当前页。" 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>
|
||||
@@ -200,7 +200,14 @@ 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') },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
|
||||
aiMatchButtonReason() {
|
||||
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
|
||||
if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细'
|
||||
if (this.aiMatchCandidates.length) return ''
|
||||
const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))]
|
||||
return reasons[0] || '所选明细不满足 AI 匹配前提'
|
||||
},
|
||||
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))] },
|
||||
@@ -266,7 +273,7 @@ export default {
|
||||
handleSelectionChange(rows) { this.selectedProducts = rows },
|
||||
isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return ready.eligible === true && ready.processStage === 'purchase_ready' },
|
||||
isCollectionCandidate(row) { const ready = this.purchaseReady(row); return ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 },
|
||||
isAIMatchCandidate(row) { return this.purchaseReady(row).processStage === 'color_mapping' },
|
||||
isAIMatchCandidate(row) { return this.purchaseReady(row).aiMatchEligible === true },
|
||||
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 ? '正在检查' : '请刷新后重试' } },
|
||||
@@ -465,6 +472,7 @@ 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-button-wrap{display:inline-flex}
|
||||
.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}
|
||||
|
||||
@@ -105,7 +105,7 @@ test('批量 AI 匹配展示混合结果并在关闭后刷新采购候选', asyn
|
||||
}
|
||||
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: '' }))
|
||||
const items = [81, 82, 83].map(id => ({ sybProductId: id, pddProductId: 200 + id, eligible: stage(id) === 'purchase_ready', collectionEligible: false, aiMatchEligible: stage(id) === 'color_mapping', aiMatchDisabledReason: stage(id) === 'color_mapping' ? '' : '已有有效确认映射,无需 AI 匹配', 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: [] }})
|
||||
|
||||
Reference in New Issue
Block a user