fix(syb): resolve uncertain specs against existing candidates #251

This commit is contained in:
QiuSW
2026-09-09 17:22:44 +08:00
parent fdd26afaf0
commit 64b4205d32
6 changed files with 233 additions and 3 deletions
@@ -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 {
+6
View File
@@ -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
+2
View File
@@ -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 【...】
@@ -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, ",")
}
@@ -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)
}
}
+8
View File
@@ -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 {