diff --git a/server/app/goauto/purchase/archive_spec_match.go b/server/app/goauto/purchase/archive_spec_match.go new file mode 100644 index 0000000..32f1a7c --- /dev/null +++ b/server/app/goauto/purchase/archive_spec_match.go @@ -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 +} diff --git a/server/app/goauto/purchase/archive_spec_match_test.go b/server/app/goauto/purchase/archive_spec_match_test.go new file mode 100644 index 0000000..29fe331 --- /dev/null +++ b/server/app/goauto/purchase/archive_spec_match_test.go @@ -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 证据时不应拒绝") + } +}