diff --git a/server/app/goauto/sybimport/sync.go b/server/app/goauto/sybimport/sync.go index 25c2d87..757dff0 100644 --- a/server/app/goauto/sybimport/sync.go +++ b/server/app/goauto/sybimport/sync.go @@ -57,6 +57,10 @@ type SyncReport struct { ShopSkipped int `json:"shopSkipped"` CharFilterSkipped int `json:"charFilterSkipped"` KeywordFilterSkipped int `json:"keywordFilterSkipped"` + // filterHits accumulates hits per filter rule id so each rule's own + // LastHitCount can be written back. It is unexported because it is run + // bookkeeping, not part of the report shown to people or stored as JSON. + filterHits sybproductfilter.HitCounter // ShopBreakdown separates accepted and skipped orders for every shop. Without // it a renamed or newly opened shop is never noticed (#50). ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"` @@ -122,7 +126,8 @@ func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client // run's effective allow-list differ from the snapshot recorded on its row. func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, allowed map[string]string, progress ProgressFunc) (SyncReport, error) { report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(), - ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed)} + ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed), + filterHits: sybproductfilter.HitCounter{}} dates, err := splitDateRange(dateFrom, dateTo) if err != nil { @@ -211,7 +216,7 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl } } report.FinishedAt = time.Now().UTC() - if err := sybproductfilter.UpdateHits(ctx, db, filters, report.CharFilterSkipped, report.KeywordFilterSkipped, report.FinishedAt); err != nil { + if err := sybproductfilter.UpdateHits(ctx, db, filters, report.filterHits, report.FinishedAt); err != nil { return report, err } return report, errors.Join(failures...) @@ -372,12 +377,20 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, } for _, item := range detail.Details { variation := stringField(item.Raw, "variationSku") - if charHit, keywordHit := filters.Match(variation); charHit || keywordHit { - if charHit { + if rule := filters.Match(variation); rule != nil { + // `[必须]` Count the two kinds separately and per rule. A combined + // total would hide a structural rule that stopped matching because + // 档口 changed its code format, and a per-kind total would make the + // disable-confirmation dialog quote the same figure for "#" and "-" + // even though they match very different numbers of rows (#269). + if rule.Kind == "char" { report.CharFilterSkipped++ } else { report.KeywordFilterSkipped++ } + if report.filterHits != nil { + report.filterHits.Add(rule) + } continue } raw, err := json.Marshal(item.Raw) diff --git a/server/app/goauto/sybproductfilter/service.go b/server/app/goauto/sybproductfilter/service.go index 4e05681..64d720b 100644 --- a/server/app/goauto/sybproductfilter/service.go +++ b/server/app/goauto/sybproductfilter/service.go @@ -192,30 +192,59 @@ func LoadEnabled(ctx context.Context, db *gorm.DB) (RuleSet, error) { } return r, nil } -func (r RuleSet) Match(v string) (char, keyword bool) { + +// Match reports which enabled rule first matched v, or nil when none did. +// +// It returns the matching rule rather than a pair of booleans because the +// caller has to attribute the hit to one specific rule: every rule keeps its +// own LastHitCount, and the disable-confirmation dialog quotes that per-rule +// number ("停用「#」会让约 N 条恢复入库"). Collapsing the result to +// char/keyword booleans would force every rule of a kind to share one +// aggregate, and the dialog would then warn with the wrong figure — on real +// data "#" matches 8498 rows and "-" matches 6392, so the two must never be +// reported as the same number. +// +// Structural rules are checked before keyword rules so a hit is attributed the +// way the page presents the two sections, and an empty variationSku never +// matches: it is a product with no supplier code yet, not a 档口 item. +func (r RuleSet) Match(v string) *models.SYBProductFilter { if v == "" { - return false, false + return nil } n := sybshop.Normalize(v) - for _, x := range r.Chars { + for i, x := range r.Chars { if strings.Contains(n, x.NormalizedKeyword) { - return true, false + return &r.Chars[i] } } - for _, x := range r.Keywords { + for i, x := range r.Keywords { if strings.Contains(n, x.NormalizedKeyword) { - return false, true + return &r.Keywords[i] } } - return false, false + return nil } -func UpdateHits(ctx context.Context, db *gorm.DB, r RuleSet, charHits, keywordHits int, at time.Time) error { - for _, x := range append(r.Chars, r.Keywords...) { - count := charHits - if x.Kind == "keyword" { - count = keywordHits - } - if err := db.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("id = ?", x.ID).Updates(map[string]any{"last_hit_count": count, "last_hit_at": at}).Error; err != nil { + +// HitCounter accumulates per-rule hits over one sync run, keyed by rule id. +type HitCounter map[uint64]int + +// Add records one hit against the rule that matched. +func (h HitCounter) Add(rule *models.SYBProductFilter) { + if rule == nil { + return + } + h[rule.ID]++ +} + +// UpdateHits writes each rule's own hit count back to its row. +// +// Every rule in the set is written, including those with zero hits: leaving a +// previous run's number in place would make the confirmation dialog quote a +// figure that no longer describes the current data. +func UpdateHits(ctx context.Context, db *gorm.DB, r RuleSet, hits HitCounter, at time.Time) error { + for _, x := range append(append([]models.SYBProductFilter{}, r.Chars...), r.Keywords...) { + if err := db.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("id = ?", x.ID). + Updates(map[string]any{"last_hit_count": hits[x.ID], "last_hit_at": at}).Error; err != nil { return err } } diff --git a/server/app/goauto/sybproductfilter/service_test.go b/server/app/goauto/sybproductfilter/service_test.go index c53b8a5..3d9bd3c 100644 --- a/server/app/goauto/sybproductfilter/service_test.go +++ b/server/app/goauto/sybproductfilter/service_test.go @@ -7,6 +7,7 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" "testing" + "time" ) func testDB(t *testing.T) *gorm.DB { @@ -21,24 +22,89 @@ func testDB(t *testing.T) *gorm.DB { } func TestMatchORAndNormalization(t *testing.T) { - r := RuleSet{Chars: []models.SYBProductFilter{{NormalizedKeyword: "-"}, {NormalizedKeyword: "#"}}, Keywords: []models.SYBProductFilter{{NormalizedKeyword: "cvc短袖220斤"}}} + r := RuleSet{ + Chars: []models.SYBProductFilter{{ID: 1, Kind: "char", NormalizedKeyword: "-"}, {ID: 2, Kind: "char", NormalizedKeyword: "#"}}, + Keywords: []models.SYBProductFilter{{ID: 3, Kind: "keyword", NormalizedKeyword: "cvc短袖220斤"}}, + } + // `[必须]` OR, never AND. Both of these contain "#" but no "-", and on real + // production data that difference is 2118 rows (#269). for _, v := range []string{"DD#004", "300斤牛奶絲圓領#A057"} { - c, k := r.Match(v) - if !c || k { - t.Fatalf("%q should char-match", v) + hit := r.Match(v) + if hit == nil || hit.Kind != "char" { + t.Fatalf("%q should match a structural rule, got %v", v, hit) + } + if hit.ID != 2 { + t.Fatalf("%q should be attributed to the \"#\" rule, got id %d", v, hit.ID) } } - if c, k := r.Match(""); c || k { - t.Fatal("empty variationSku must not match") + if hit := r.Match("新寮-雅伊阁大码女装"); hit == nil || hit.ID != 1 { + t.Fatalf("dash-only value should be attributed to the \"-\" rule, got %v", hit) } - if c, k := r.Match(" CVC短袖220斤 "); c || !k { - t.Fatal("full-width/space keyword should match") + if hit := r.Match(""); hit != nil { + t.Fatal("empty variationSku must not match: it is a product without a supplier code") } - if _, k := r.Match("CVC短袖220斤"); k != true { - t.Fatal("lowercase keyword should match") + if hit := r.Match("PDD0582"); hit != nil { + t.Fatalf("own product code must not match, got %v", hit) } - if _, k := r.Match("純棉"); k { - t.Fatal("normalization must not simplify traditional Chinese") + if hit := r.Match(" CVC短袖220斤 "); hit == nil || hit.Kind != "keyword" { + t.Fatalf("full-width and surrounding spaces should normalize to a keyword hit, got %v", hit) + } + if hit := r.Match("cvc短袖220斤"); hit == nil || hit.Kind != "keyword" { + t.Fatalf("lowercase should normalize to a keyword hit, got %v", hit) + } + if hit := r.Match("纯棉"); hit != nil { + t.Fatal("normalization must not convert traditional to simplified Chinese") + } +} + +// TestUpdateHitsIsPerRule pins the rule that每条规则各记各的命中数. Writing one +// aggregate per kind would make the disable-confirmation dialog quote the same +// figure for "#" and "-" even though they match very different numbers of rows. +func TestUpdateHitsIsPerRule(t *testing.T) { + db := testDB(t) + ctx := context.Background() + set, err := LoadEnabled(ctx, db) + if err != nil { + t.Fatal(err) + } + if len(set.Chars) != 2 { + t.Fatalf("migration should seed 2 structural rules, got %d", len(set.Chars)) + } + byKeyword := map[string]uint64{} + for _, x := range set.Chars { + byKeyword[x.Keyword] = x.ID + } + hits := HitCounter{} + for i := 0; i < 8498; i++ { + hits[byKeyword["#"]]++ + } + for i := 0; i < 6392; i++ { + hits[byKeyword["-"]]++ + } + if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil { + t.Fatal(err) + } + var rows []models.SYBProductFilter + if err := db.Find(&rows).Error; err != nil { + t.Fatal(err) + } + want := map[string]int{"#": 8498, "-": 6392} + for _, x := range rows { + if x.LastHitCount == nil { + t.Fatalf("rule %q must be written even when it matched nothing", x.Keyword) + } + if w, ok := want[x.Keyword]; ok { + if *x.LastHitCount != w { + t.Fatalf("rule %q should record its own %d hits, got %d", x.Keyword, w, *x.LastHitCount) + } + continue + } + if *x.LastHitCount != 0 { + t.Fatalf("keyword rule %q matched nothing and must record 0, got %d", x.Keyword, *x.LastHitCount) + } + if x.LastHitAt == nil { + t.Fatalf("rule %q must still be stamped so a stale count is never left behind", x.Keyword) + } } } diff --git a/web/src/views/goauto/syb-product-filters/index.vue b/web/src/views/goauto/syb-product-filters/index.vue index a982be4..86146ee 100644 --- a/web/src/views/goauto/syb-product-filters/index.vue +++ b/web/src/views/goauto/syb-product-filters/index.vue @@ -1,19 +1,319 @@ + - + +