diff --git a/server/app/goauto/sybimport/ai_parse_batch_test.go b/server/app/goauto/sybimport/ai_parse_batch_test.go index cdbdc8f..f5d99f1 100644 --- a/server/app/goauto/sybimport/ai_parse_batch_test.go +++ b/server/app/goauto/sybimport/ai_parse_batch_test.go @@ -44,6 +44,30 @@ func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID return applied.SYBProduct } +func TestScheduledParseUsesUniqueCandidatesBeforeCallingAI(t *testing.T) { + var calls atomic.Int32 + provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + calls.Add(1) + response.WriteHeader(http.StatusInternalServerError) + })) + defer provider.Close() + db := openTestDB(t) + record := seedAIParseCandidate(t, db, provider.URL, 25102, "黑色 XL") + run, _, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) + if err != nil { + t.Fatal(err) + } + if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil { + t.Fatal(err) + } + if err := db.First(&record, record.ID).Error; err != nil { + t.Fatal(err) + } + if record.ParseStatus != models.SYBParseStatusSuccess || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfirmed || calls.Load() != 0 { + t.Fatalf("unique candidate must resolve deterministically, status=%s ai=%v calls=%d", record.ParseStatus, record.AIConfirmed, calls.Load()) + } +} + func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) { var calls atomic.Int32 provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { @@ -53,7 +77,7 @@ func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) })) defer provider.Close() db := openTestDB(t) - record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL") + record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑 XL") rawBefore := record.RawJSON run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) if err != nil || !created { @@ -98,7 +122,7 @@ func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testi })) defer provider.Close() db := openTestDB(t) - record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL") + record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑 XL") for i := 0; i < 2; i++ { run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) if err != nil || !created { @@ -175,7 +199,7 @@ func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) { })) defer provider.Close() db := openTestDB(t) - record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL") + record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑 XL") for attempt := 1; attempt <= 4; attempt++ { run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20) if err != nil || !created { diff --git a/server/app/goauto/sybimport/apply.go b/server/app/goauto/sybimport/apply.go index 8e18a2b..83ad28c 100644 --- a/server/app/goauto/sybimport/apply.go +++ b/server/app/goauto/sybimport/apply.go @@ -96,6 +96,9 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta if err != nil { return err } + if shopeeProduct != nil { + parsed = ParseWithCandidates(detail.ProductSpec, shopeeProduct.SpecsJSON) + } if parsed.Status == models.SYBParseStatusSuccess && shopeeProduct != nil { if err := mergeParsedSpec(tx, shopeeProduct.ID, parsed); err != nil { return err @@ -271,6 +274,9 @@ func fillEmptyArchiveFields(tx *gorm.DB, product *models.ShopeeProduct, detail D // review but are never pushed into the shared archive, so a messy raw string // never pollutes the spec list other screens read from. func mergeParsedSpec(tx *gorm.DB, shopeeProductID uint64, parsed ParseResult) error { + if parsed.matchedCandidates { + return nil + } var product models.ShopeeProduct if err := tx.First(&product, shopeeProductID).Error; err != nil { return err diff --git a/server/app/goauto/sybimport/parse.go b/server/app/goauto/sybimport/parse.go index d82fd60..9138089 100644 --- a/server/app/goauto/sybimport/parse.go +++ b/server/app/goauto/sybimport/parse.go @@ -34,6 +34,8 @@ type ParseResult struct { Size string Status string Note string + // Already present in the archive; do not re-merge or modify mappings. + matchedCandidates bool } // Parse implements the #41/#216 rule: split on the last comma, strip 【...】 diff --git a/server/app/goauto/sybimport/parse_candidates.go b/server/app/goauto/sybimport/parse_candidates.go new file mode 100644 index 0000000..9d505f1 --- /dev/null +++ b/server/app/goauto/sybimport/parse_candidates.go @@ -0,0 +1,69 @@ +package sybimport + +import ( + "strings" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" +) + +// ParseWithCandidates only rescues uncertain parses against previously stored +// Shopee labels. It never uses the current unconfirmed input as its own evidence. +func ParseWithCandidates(raw, specsJSON string) ParseResult { + baseline := Parse(raw) + if baseline.Status != models.SYBParseStatusUncertain { + return baseline + } + specs, err := shopeeproduct.Unmarshal(specsJSON) + if err != nil || shopeeproduct.Validate(specs) != nil { + return baseline + } + for _, dimension := range specs { + if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize && len(dimension.Values) > 0 { + return baseline + } + } + colors, sizes, ambiguous := closedShopeeCandidates(specs) + if ambiguous || len(colors)+len(sizes) == 0 { + return baseline + } + if len(colors) == 0 { + colors = []string{""} + } + if len(sizes) == 0 { + sizes = []string{""} + } + input := normalizeCandidateSpec(stripBrackets(raw)) + type pair struct{ color, size string } + matches := map[pair]bool{} + for _, color := range colors { + for _, size := range sizes { + variants := []string{color + "," + size, size + "," + color, color + " " + size, size + " " + color} + if color == "" || size == "" { + variants = []string{color + size} + } + for _, variant := range variants { + if input == normalizeCandidateSpec(variant) { + matches[pair{color, size}] = true + } + } + } + } + if len(matches) != 1 { + return baseline + } + for match := range matches { + return ParseResult{Color: match.color, Size: match.size, Status: models.SYBParseStatusSuccess, + Note: "已按蝦皮既有规格候选唯一核对", matchedCandidates: true} + } + return baseline +} + +func normalizeCandidateSpec(value string) string { + value = strings.ReplaceAll(value, ",", ",") + parts := strings.Split(value, ",") + for i, part := range parts { + parts[i] = strings.Join(strings.Fields(part), " ") + } + return strings.Join(parts, ",") +} diff --git a/server/app/goauto/sybimport/parse_candidates_test.go b/server/app/goauto/sybimport/parse_candidates_test.go new file mode 100644 index 0000000..a5f461e --- /dev/null +++ b/server/app/goauto/sybimport/parse_candidates_test.go @@ -0,0 +1,121 @@ +package sybimport_test + +import ( + "context" + "encoding/json" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" + "go-admin/app/goauto/sybimport" +) + +func candidateSpecs(colors, sizes []string) string { + dimensions := []shopeeproduct.SpecDimension{} + for _, input := range []struct { + role string + values []string + }{{"color", colors}, {"size", sizes}} { + if len(input.values) == 0 { + continue + } + dimension := shopeeproduct.SpecDimension{Name: input.role, Role: input.role} + for _, value := range input.values { + dimension.Values = append(dimension.Values, shopeeproduct.SpecValue{Name: value, Source: shopeeproduct.ValueSourceImport}) + } + dimensions = append(dimensions, dimension) + } + encoded, _ := json.Marshal(dimensions) + return string(encoded) +} + +func TestCandidateParseRescuesOnlyUniqueWholeInput(t *testing.T) { + for _, test := range []struct { + name, raw string + colors, sizes []string + color, size string + }{ + {"combined color", "黑色+白色,L", []string{"黑色+白色", "黑色"}, []string{"L", "XL"}, "黑色+白色", "L"}, + {"spaces in color", "浅 灰色,XL", []string{"浅 灰色"}, []string{"XL"}, "浅 灰色", "XL"}, + {"full width comma", "黑色,XL", []string{"黑色"}, []string{"XL"}, "黑色", "XL"}, + {"reverse full width", "XL,黑色", []string{"黑色"}, []string{"XL"}, "黑色", "XL"}, + {"space separator", "黑色 XL", []string{"黑色"}, []string{"XL"}, "黑色", "XL"}, + {"size only", "均碼", nil, []string{"均碼"}, "", "均碼"}, + {"color only", "黑色+白色", []string{"黑色+白色"}, nil, "黑色+白色", ""}, + {"weight description exact", "黑色,XL 建議55.5-60.0公斤穿", []string{"黑色"}, []string{"XL 建議55.5-60.0公斤穿"}, "黑色", "XL 建議55.5-60.0公斤穿"}, + } { + t.Run(test.name, func(t *testing.T) { + got := sybimport.ParseWithCandidates(test.raw, candidateSpecs(test.colors, test.sizes)) + if got.Status != models.SYBParseStatusSuccess || got.Color != test.color || got.Size != test.size { + t.Fatalf("unexpected parse: %+v", got) + } + }) + } +} + +func TestCandidateParsePreservesAmbiguityAndNormalResults(t *testing.T) { + for _, test := range []struct{ raw, specs string }{ + {"", candidateSpecs([]string{"黑色"}, []string{"XL"})}, + {"黑色 XL", "broken"}, + {"黑色 XL", "[]"}, + {"XL", candidateSpecs([]string{"黑色"}, []string{"XL"})}, + {"黑色+白色 XL", candidateSpecs([]string{"黑色", "白色"}, []string{"XL"})}, + {"黑色 XL 多余描述", candidateSpecs([]string{"黑色"}, []string{"XL"})}, + {"黑色 XL 建議55.5-60.0公斤穿", candidateSpecs([]string{"黑色"}, []string{"XL"})}, + {"黑色,XL", candidateSpecs([]string{"黑色", "XL"}, []string{"XL", "黑色"})}, + {"浅 灰色 XL", candidateSpecs([]string{"浅 灰色", "浅 灰色"}, []string{"XL"})}, + {"白色,L", candidateSpecs([]string{"黑色"}, []string{"XL"})}, + {"黑色 XL", `[{"name":"a","role":"color","values":[{"name":"黑色"}]},{"name":"b","role":"color","values":[{"name":"白色"}]},{"name":"s","role":"size","values":[{"name":"XL"}]}]`}, + {"黑色 XL", `[{"name":"a","role":"other","values":[{"name":"黑色 XL"}]}]`}, + } { + if got, want := sybimport.ParseWithCandidates(test.raw, test.specs), sybimport.Parse(test.raw); got != want { + t.Fatalf("raw %q changed: %+v vs %+v", test.raw, got, want) + } + } +} + +func TestImportAndReparseReuseExistingCandidatesWithoutChangingMappings(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + detail := sybimport.DetailInput{ID: 25101, ProductID: 25101, ProductQty: 1, ProductPrice: 1, ProductSpec: "黑色+白色,XL", Raw: json.RawMessage(`{"productSpec":"黑色+白色,XL"}`)} + order := sybimport.OrderInput{Code: "test-251", ShopName: "test"} + first, err := sybimport.ApplyDetail(ctx, db, order, detail) + if err != nil { + t.Fatal(err) + } + if first.SYBProduct.ParseStatus != models.SYBParseStatusUncertain { + t.Fatal("must not self-confirm without candidates") + } + specs := candidateSpecs([]string{"黑色+白色"}, []string{"XL"}) + if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *first.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil { + t.Fatal(err) + } + outcome, err := sybimport.Reparse(ctx, db, first.SYBProduct.ID, false) + if err != nil || outcome.NewStatus != models.SYBParseStatusSuccess { + t.Fatalf("reparse: %+v %v", outcome, err) + } + second, err := sybimport.ApplyDetail(ctx, db, order, detail) + if err != nil { + t.Fatal(err) + } + if second.SYBProduct.TargetColor != "黑色+白色" || second.SYBProduct.TargetSize != "XL" || second.SYBProduct.ParseStatus != models.SYBParseStatusSuccess { + t.Fatalf("import differs: %+v", second.SYBProduct) + } + if second.SYBProduct.RawJSON != first.SYBProduct.RawJSON { + t.Fatal("raw source changed") + } + if second.ShopeeProduct.SpecsJSON != specs { + t.Fatal("candidate confirmation must not rewrite archive dimensions or mappings") + } + if _, err := sybimport.ManualCorrect(ctx, db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil { + t.Fatal(err) + } + outcome, err = sybimport.Reparse(ctx, db, first.SYBProduct.ID, false) + if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedManual { + t.Fatalf("manual overwritten: %+v %v", outcome, err) + } + third, err := sybimport.ApplyDetail(ctx, db, order, detail) + if err != nil || third.SYBProduct.TargetColor != "人工颜色" { + t.Fatalf("reimport overwrote manual: %v", err) + } +} diff --git a/server/app/goauto/sybimport/reparse.go b/server/app/goauto/sybimport/reparse.go index 195056d..681f5c9 100644 --- a/server/app/goauto/sybimport/reparse.go +++ b/server/app/goauto/sybimport/reparse.go @@ -77,6 +77,14 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool) return fmt.Errorf("syb product %d: stored raw json is not decodable: %w", record.ID, err) } parsed := Parse(raw.ProductSpec) + if parsed.Status == models.SYBParseStatusUncertain && record.ShopeeProductID != nil { + var product models.ShopeeProduct + if err := tx.First(&product, *record.ShopeeProductID).Error; err == nil { + parsed = ParseWithCandidates(raw.ProductSpec, product.SpecsJSON) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } outcome.NewStatus = parsed.Status if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {