fix(server): 规格同步后匹配档案里所有未映射的规格值 (#293)
BatchSpecMatch 遍历的是 SYB 明细,只匹配明细需要的值。#290 把虾皮完整颜色尺码 同步进档案后,还没有订单的值一个都不会被尝试——等订单真来了仍要人工点一次匹配, 正是 #290 想消除的动作。 新增 MatchArchiveSpecs:对档案里每个未确认映射的值逐个匹配,确定性优先、AI 兜底。 - 只写映射,不创建采购。人工检查点不变。 - 逐值匹配没有另一半规格,无法校验完整可售组合,因此至少要求该值出现在某个在售 SKU 里;完整组合仍由采购预检把关。 - 没有 SKU 证据时放行,与 aiMatchQualificationForDataset 的既有口径一致。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ArchiveSpecMatchResult counts what one archive-wide matching pass did.
|
||||
type ArchiveSpecMatchResult struct {
|
||||
Confirmed int
|
||||
Skipped int
|
||||
Failed int
|
||||
}
|
||||
|
||||
// MatchArchiveSpecs matches every still-unmapped value in a Shopee product's
|
||||
// archive against its linked PDD product (#293).
|
||||
//
|
||||
// 存在的理由:BatchSpecMatch 遍历的是 SYB 明细,只匹配明细需要的值。#290 把虾皮
|
||||
// 的完整颜色尺码同步进档案后,那些还没有订单的值一个都不会被尝试——等订单真来了
|
||||
// 仍然要人工点一次匹配,正是 #290 想消除的动作。
|
||||
//
|
||||
// `[必须]` 这里只写规格映射,不创建任何采购。匹配只把明细推到“采购就绪”,创建
|
||||
// 采购仍由人点击——那是整条链上唯一的人工检查点。
|
||||
func (s *Service) MatchArchiveSpecs(ctx context.Context, shopeeProductID uint64) (ArchiveSpecMatchResult, error) {
|
||||
var result ArchiveSpecMatchResult
|
||||
var shopee models.ShopeeProduct
|
||||
if err := s.DB.WithContext(ctx).First(&shopee, shopeeProductID).Error; err != nil {
|
||||
return result, internal(err)
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return result, nil
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil {
|
||||
return result, internal(err)
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return result, nil
|
||||
}
|
||||
candidates, usable := archiveCandidates(pdd.SpecsJSON, "", "")
|
||||
if !usable {
|
||||
return result, nil
|
||||
}
|
||||
combinations, err := s.archiveSKUCombinations(ctx, pdd.ID)
|
||||
if err != nil {
|
||||
return result, internal(err)
|
||||
}
|
||||
|
||||
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
return result, nil
|
||||
}
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Mapping != nil && value.Mapping.Status == shopeeproduct.MappingStatusConfirmed {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if !s.matchOneArchiveValue(ctx, shopeeProductID, dimension, value.Name, candidates, combinations) {
|
||||
result.Failed++
|
||||
continue
|
||||
}
|
||||
result.Confirmed++
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// matchOneArchiveValue resolves a single archive value and persists it when the
|
||||
// answer survives every check. It reports whether a mapping was written.
|
||||
func (s *Service) matchOneArchiveValue(
|
||||
ctx context.Context,
|
||||
shopeeProductID uint64,
|
||||
dimension shopeeproduct.SpecDimension,
|
||||
valueName string,
|
||||
candidates specCandidates,
|
||||
combinations []pddSKUCombination,
|
||||
) bool {
|
||||
isColor := dimension.Role == shopeeproduct.RoleColor
|
||||
request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if isColor {
|
||||
request.TargetColor = valueName
|
||||
} else {
|
||||
request.TargetSize = valueName
|
||||
}
|
||||
matched, ok := aimatching.DeterministicMatch(request)
|
||||
if !ok {
|
||||
resolved, err := s.matcher().Resolve(ctx, request)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// #200 的口径:AI 只要给出带理由的结果就可保存,是否放行交给下面的候选与
|
||||
// 可售组合校验;置信度只作审计信息。
|
||||
if resolved.Source != aimatching.SourceAI || strings.TrimSpace(resolved.Decision.Reason) == "" {
|
||||
return false
|
||||
}
|
||||
matched = resolved
|
||||
}
|
||||
|
||||
pddValue := matched.MappedSize
|
||||
allowed := candidates.Sizes
|
||||
if isColor {
|
||||
pddValue, allowed = matched.MappedColor, candidates.Colors
|
||||
}
|
||||
pddValue = strings.TrimSpace(pddValue)
|
||||
if pddValue == "" || !containsValue(allowed, pddValue) {
|
||||
return false
|
||||
}
|
||||
// `[必须]` 逐值匹配没有另一半规格,无法校验完整的可售组合。至少要求这个值真的
|
||||
// 出现在某个在售 SKU 里,否则会把一个“看起来合理但根本买不到”的值写成确认映射。
|
||||
// 完整组合仍由采购预检把关。
|
||||
if !archiveValueIsSellable(combinations, isColor, pddValue) {
|
||||
return false
|
||||
}
|
||||
|
||||
item := shopeeproduct.ResolvedMappingItem{
|
||||
Dimension: dimension.Name, ValueName: valueName, PDDValue: pddValue,
|
||||
Source: matched.Source, Status: shopeeproduct.MappingStatusConfirmed,
|
||||
Confidence: matched.Decision.Confidence, Reason: strings.TrimSpace(matched.Decision.Reason),
|
||||
}
|
||||
_, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopeeProductID, uuid.NewString(), []shopeeproduct.ResolvedMappingItem{item})
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// archiveValueIsSellable reports whether the PDD value appears in at least one
|
||||
// available SKU combination. No combinations means no evidence either way, so
|
||||
// the value passes — hand-entered or imported PDD archives have no SKU rows.
|
||||
func archiveValueIsSellable(combinations []pddSKUCombination, isColor bool, pddValue string) bool {
|
||||
if len(combinations) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, combination := range combinations {
|
||||
if isColor && combination.Color == pddValue {
|
||||
return true
|
||||
}
|
||||
if !isColor && combination.Size == pddValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) archiveSKUCombinations(ctx context.Context, pddProductID uint64) ([]pddSKUCombination, error) {
|
||||
dataset := batchPreviewDataset{skuCombinationsByPDD: make(map[uint64][]pddSKUCombination)}
|
||||
if err := s.loadLatestSKUCombinations(ctx, []uint64{pddProductID}, &dataset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataset.skuCombinationsByPDD[pddProductID], nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package purchase
|
||||
|
||||
import "testing"
|
||||
|
||||
// `[必须]` 逐值匹配没有另一半规格,无法校验完整的可售组合,所以至少要求这个值真的
|
||||
// 出现在某个在售 SKU 里。否则会把“看起来合理但根本买不到”的值写成确认映射(#293)。
|
||||
func TestArchiveValueMustAppearInASellableSKU(t *testing.T) {
|
||||
combinations := []pddSKUCombination{
|
||||
{Color: "紫色-冰块猫", Size: "M 建议86-95斤"},
|
||||
{Color: "黑色-冰块猫", Size: "L 建议96-105斤"},
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
isColor bool
|
||||
pddValue string
|
||||
want bool
|
||||
}{
|
||||
{"在售颜色", true, "黑色-冰块猫", true},
|
||||
{"不在售颜色", true, "奶黄-百花", false},
|
||||
{"在售尺码", false, "L 建议96-105斤", true},
|
||||
{"不在售尺码", false, "4XL 建议136-150斤", false},
|
||||
{"颜色值不能拿去顶尺码", false, "黑色-冰块猫", false},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
if got := archiveValueIsSellable(combinations, item.isColor, item.pddValue); got != item.want {
|
||||
t.Fatalf("got %v, want %v", got, item.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 人工录入或外部导入的 PDD 档案没有采集 SKU 证据。没有证据时不能一律拒绝,
|
||||
// 否则这些商品永远匹配不上——与 aiMatchQualificationForDataset 的既有口径一致。
|
||||
func TestArchiveValuePassesWhenThereIsNoSKUEvidence(t *testing.T) {
|
||||
if !archiveValueIsSellable(nil, true, "任意颜色") {
|
||||
t.Fatal("没有 SKU 证据时不应拒绝")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user