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
|
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) {
|
func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) {
|
||||||
var calls atomic.Int32
|
var calls atomic.Int32
|
||||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
@@ -53,7 +77,7 @@ func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T)
|
|||||||
}))
|
}))
|
||||||
defer provider.Close()
|
defer provider.Close()
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL")
|
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑 XL")
|
||||||
rawBefore := record.RawJSON
|
rawBefore := record.RawJSON
|
||||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||||
if err != nil || !created {
|
if err != nil || !created {
|
||||||
@@ -98,7 +122,7 @@ func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testi
|
|||||||
}))
|
}))
|
||||||
defer provider.Close()
|
defer provider.Close()
|
||||||
db := openTestDB(t)
|
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++ {
|
for i := 0; i < 2; i++ {
|
||||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||||
if err != nil || !created {
|
if err != nil || !created {
|
||||||
@@ -175,7 +199,7 @@ func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer provider.Close()
|
defer provider.Close()
|
||||||
db := openTestDB(t)
|
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++ {
|
for attempt := 1; attempt <= 4; attempt++ {
|
||||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||||
if err != nil || !created {
|
if err != nil || !created {
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if shopeeProduct != nil {
|
||||||
|
parsed = ParseWithCandidates(detail.ProductSpec, shopeeProduct.SpecsJSON)
|
||||||
|
}
|
||||||
if parsed.Status == models.SYBParseStatusSuccess && shopeeProduct != nil {
|
if parsed.Status == models.SYBParseStatusSuccess && shopeeProduct != nil {
|
||||||
if err := mergeParsedSpec(tx, shopeeProduct.ID, parsed); err != nil {
|
if err := mergeParsedSpec(tx, shopeeProduct.ID, parsed); err != nil {
|
||||||
return err
|
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
|
// review but are never pushed into the shared archive, so a messy raw string
|
||||||
// never pollutes the spec list other screens read from.
|
// never pollutes the spec list other screens read from.
|
||||||
func mergeParsedSpec(tx *gorm.DB, shopeeProductID uint64, parsed ParseResult) error {
|
func mergeParsedSpec(tx *gorm.DB, shopeeProductID uint64, parsed ParseResult) error {
|
||||||
|
if parsed.matchedCandidates {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var product models.ShopeeProduct
|
var product models.ShopeeProduct
|
||||||
if err := tx.First(&product, shopeeProductID).Error; err != nil {
|
if err := tx.First(&product, shopeeProductID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type ParseResult struct {
|
|||||||
Size string
|
Size string
|
||||||
Status string
|
Status string
|
||||||
Note 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 【...】
|
// 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)
|
return fmt.Errorf("syb product %d: stored raw json is not decodable: %w", record.ID, err)
|
||||||
}
|
}
|
||||||
parsed := Parse(raw.ProductSpec)
|
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
|
outcome.NewStatus = parsed.Status
|
||||||
|
|
||||||
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
|
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
|
||||||
|
|||||||
Reference in New Issue
Block a user