fix(syb): resolve uncertain specs against existing candidates #251
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user