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:
QiuSW
2026-09-16 11:53:43 +08:00
co-authored by Claude Opus 5
parent bb097a6a15
commit 1274161a1d
2 changed files with 197 additions and 0 deletions
@@ -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 证据时不应拒绝")
}
}