diff --git a/server/app/goauto/shopeeproduct/ai_suggest.go b/server/app/goauto/shopeeproduct/ai_suggest.go index f14ff21..9f999fe 100644 --- a/server/app/goauto/shopeeproduct/ai_suggest.go +++ b/server/app/goauto/shopeeproduct/ai_suggest.go @@ -131,6 +131,12 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC item.Reason = "保留已确认映射" item.Apply = true response.MatchedCount++ + case role == RoleColor && deterministicColorMatch(value.Name, candidateNames) != "": + item.PDDValue = deterministicColorMatch(value.Name, candidateNames) + item.Status = "matched" + item.Reason = "格式统一后唯一匹配" + item.Apply = true + response.MatchedCount++ case role == RoleSize && deterministicSizeMatch(value.Name, candidateNames) != "": item.PDDValue = deterministicSizeMatch(value.Name, candidateNames) item.Status = "matched" @@ -237,6 +243,15 @@ func deterministicSizeMatch(value string, candidates []string) string { return match.MappedSize } +// Keep the original candidate label; normalization is only a comparison key. +func deterministicColorMatch(value string, candidates []string) string { + match, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: value, Colors: candidates}) + if !ok { + return "" + } + return match.MappedColor +} + // aiSuggestErrorMessage turns an aimatching error into a message safe to // return to the admin UI (no credential, no raw provider body). func aiSuggestErrorMessage(err error) string { diff --git a/server/app/goauto/shopeeproduct/color_normalized_test.go b/server/app/goauto/shopeeproduct/color_normalized_test.go new file mode 100644 index 0000000..1373e5d --- /dev/null +++ b/server/app/goauto/shopeeproduct/color_normalized_test.go @@ -0,0 +1,100 @@ +package shopeeproduct + +import ( + "context" + "testing" + + "github.com/google/uuid" + "go-admin/app/goauto/models" +) + +func TestColorNormalizedMatchWithoutAIKeepsOriginalLabels(t *testing.T) { + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + pdd.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"260#大红","selectable":true},{"name":"260#墨绿","selectable":true},{"name":"AbC黑色","selectable":true}]}]` + if err := db.Save(&pdd).Error; err != nil { + t.Fatal(err) + } + service := NewService(db) + sources := []string{"260#大紅", "260#墨綠", "abc 黑色"} + targets := []string{"260#大红", "260#墨绿", "AbC黑色"} + values := []SpecValue{} + for _, name := range sources { + values = append(values, SpecValue{Name: name, Source: ValueSourceImport}) + } + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "normalized-color", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: values}}}) + if err != nil { + t.Fatal(err) + } + preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion) + if err != nil { + t.Fatalf("exact colors must not require AI: %v", err) + } + for i, item := range preview.Items { + if item.Status != "matched" || !item.Apply || item.ValueName != sources[i] || item.PDDValue != targets[i] { + t.Fatalf("unexpected preview: %+v", item) + } + } + if len(preview.Items) != 3 || preview.MatchedCount != 3 { + t.Fatal("missing exact matches") + } + var stored models.ShopeeProduct + if err := db.First(&stored, created.Product.ID).Error; err != nil { + t.Fatal(err) + } + specs, err := Unmarshal(stored.SpecsJSON) + if err != nil { + t.Fatal(err) + } + for _, value := range specs[0].Values { + if value.Mapping != nil { + t.Fatal("preview wrote mappings") + } + } + result, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}) + if err != nil { + t.Fatal(err) + } + if result.ConfirmedCount != 3 || result.UnmatchedCount != 0 { + t.Fatalf("unexpected counts: %+v", result) + } + for i, value := range result.Product.Specs[0].Values { + m := value.Mapping + if value.Name != sources[i] || m == nil || m.PDDValue != targets[i] || m.Source != MappingSourceExactMatch || m.Status != MappingStatusConfirmed { + t.Fatalf("incorrect persisted mapping: %+v", value) + } + } + var after models.PDDProduct + if err := db.First(&after, pdd.ID).Error; err != nil { + t.Fatal(err) + } + if after.SpecsJSON != pdd.SpecsJSON { + t.Fatal("changed original PDD specs") + } +} + +func TestColorNormalizedAmbiguityAndUnavailableStillRequireAI(t *testing.T) { + for _, tc := range []struct{ name, source, specs string }{ + {"ambiguous", "大紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":true},{"name":"大紅","selectable":true}]}]`}, + {"unavailable", "大紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":false},{"name":"黑色","selectable":true}]}]`}, + {"unmatched", "深紅", `[{"name":"颜色","role":"color","values":[{"name":"大红","selectable":true}]}]`}, + } { + t.Run(tc.name, func(t *testing.T) { + db := openTestDB(t) + pdd := seedPDDProduct(t, db, "active") + pdd.SpecsJSON = tc.specs + if err := db.Save(&pdd).Error; err != nil { + t.Fatal(err) + } + service := NewService(db) + created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: tc.name, PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: tc.source, Source: ValueSourceImport}}}}}) + if err != nil { + t.Fatal(err) + } + _, err = service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion) + if err == nil || errCode(t, err) != CodeAIUnavailable { + t.Fatalf("must fall back to AI, not guess: %v", err) + } + }) + } +}