diff --git a/server/app/goauto/sybproductfilter/recompute.go b/server/app/goauto/sybproductfilter/recompute.go
index e2d4ece..e628f9f 100644
--- a/server/app/goauto/sybproductfilter/recompute.go
+++ b/server/app/goauto/sybproductfilter/recompute.go
@@ -22,11 +22,32 @@ type RecomputeCounts struct {
type recomputeChange struct {
id uint64
+ orderCode string
+ shopeeItemID string
toExcluded bool // true: pdd -> excluded; false: excluded -> pdd
ruleID *uint64
ruleKind, ruleKeyword string
}
+// RecomputeSample is one preview row (#340 phase 2): up to maxRecomputeSamples
+// changes are shown so an admin can sanity-check the run before executing it.
+type RecomputeSample struct {
+ OrderCode string `json:"orderCode"`
+ ShopeeItemID string `json:"shopeeItemId"`
+ // Direction is "excluded_to_pdd" or "pdd_to_excluded".
+ Direction string `json:"direction"`
+ // RuleKind/RuleKeyword are empty when Direction is excluded_to_pdd (no
+ // rule matches any more).
+ RuleKind string `json:"ruleKind,omitempty"`
+ RuleKeyword string `json:"ruleKeyword,omitempty"`
+}
+
+const (
+ DirectionExcludedToPDD = "excluded_to_pdd"
+ DirectionPDDToExcluded = "pdd_to_excluded"
+ maxRecomputeSamples = 20
+)
+
// recomputeChanges computes, against the CURRENT enabled rules, every
// syb_product row whose mark should flip, skipping any row that has ever had
// a purchase task or currently has an active return match — those never
@@ -76,7 +97,7 @@ func recomputeChanges(ctx context.Context, tx *gorm.DB) (RecomputeCounts, []reco
counts.SkippedReturnMatch++
continue
}
- change := recomputeChange{id: row.ID, toExcluded: wouldExclude}
+ change := recomputeChange{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: wouldExclude}
if wouldExclude {
ruleID := rule.ID
change.ruleID, change.ruleKind, change.ruleKeyword = &ruleID, rule.Kind, rule.Keyword
@@ -103,14 +124,36 @@ func recomputeVariationSku(rawJSON string) string {
return value
}
-// RecomputePreview is read-only: it must produce exactly the counts execute
-// would produce.
-func (s *Service) RecomputePreview(ctx context.Context) (RecomputeCounts, error) {
- counts, _, err := recomputeChanges(ctx, s.DB)
+// RecomputePreviewResult is preview-only: it carries the same RecomputeCounts
+// execute will produce, plus up to maxRecomputeSamples sample rows so an
+// admin can see what would actually change before confirming.
+type RecomputePreviewResult struct {
+ RecomputeCounts
+ Samples []RecomputeSample `json:"samples"`
+}
+
+// RecomputePreview is read-only: its counts must exactly match what execute
+// would produce (both are computed by the same recomputeChanges call).
+func (s *Service) RecomputePreview(ctx context.Context) (RecomputePreviewResult, error) {
+ counts, changes, err := recomputeChanges(ctx, s.DB)
if err != nil {
- return RecomputeCounts{}, internal(err)
+ return RecomputePreviewResult{}, internal(err)
}
- return counts, nil
+ samples := make([]RecomputeSample, 0, maxRecomputeSamples)
+ for _, change := range changes {
+ if len(samples) >= maxRecomputeSamples {
+ break
+ }
+ sample := RecomputeSample{OrderCode: change.orderCode, ShopeeItemID: change.shopeeItemID}
+ if change.toExcluded {
+ sample.Direction = DirectionPDDToExcluded
+ sample.RuleKind, sample.RuleKeyword = change.ruleKind, change.ruleKeyword
+ } else {
+ sample.Direction = DirectionExcludedToPDD
+ }
+ samples = append(samples, sample)
+ }
+ return RecomputePreviewResult{RecomputeCounts: counts, Samples: samples}, nil
}
type RecomputeExecuteResult struct {
diff --git a/server/app/goauto/sybproductfilter/recompute_test.go b/server/app/goauto/sybproductfilter/recompute_test.go
index f503d3a..f01d34c 100644
--- a/server/app/goauto/sybproductfilter/recompute_test.go
+++ b/server/app/goauto/sybproductfilter/recompute_test.go
@@ -2,6 +2,7 @@ package sybproductfilter
import (
"context"
+ "fmt"
"testing"
"go-admin/app/goauto/models"
@@ -65,8 +66,31 @@ func TestRecomputePreviewMatchesExecute(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if result.RecomputeCounts != preview {
- t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview, result.RecomputeCounts)
+ if result.RecomputeCounts != preview.RecomputeCounts {
+ t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview.RecomputeCounts, result.RecomputeCounts)
+ }
+ if len(preview.Samples) != 2 {
+ t.Fatalf("expected 2 sample rows, got %+v", preview.Samples)
+ }
+ var sawPDDToExcluded, sawExcludedToPDD bool
+ for _, sample := range preview.Samples {
+ switch sample.Direction {
+ case DirectionPDDToExcluded:
+ sawPDDToExcluded = true
+ if sample.OrderCode != "ORD-1" || sample.RuleKeyword != "档口" {
+ t.Fatalf("unexpected pdd_to_excluded sample: %+v", sample)
+ }
+ case DirectionExcludedToPDD:
+ sawExcludedToPDD = true
+ if sample.OrderCode != "ORD-2" || sample.RuleKind != "" || sample.RuleKeyword != "" {
+ t.Fatalf("unexpected excluded_to_pdd sample: %+v", sample)
+ }
+ default:
+ t.Fatalf("unexpected direction: %+v", sample)
+ }
+ }
+ if !sawPDDToExcluded || !sawExcludedToPDD {
+ t.Fatalf("expected both directions represented: %+v", preview.Samples)
}
var reloadedPDDToExcluded, reloadedExcludedToPDD, reloadedHasTask, reloadedHasMatch models.SYBProduct
@@ -105,3 +129,31 @@ func TestRecomputePreviewMatchesExecute(t *testing.T) {
t.Fatalf("expected a no-op second preview, got %+v", secondPreview)
}
}
+
+func TestRecomputePreviewCapsSamplesAt20(t *testing.T) {
+ db := testDB(t)
+ if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 25; i++ {
+ row := models.SYBProduct{
+ OrderCode: fmt.Sprintf("ORD-CAP-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1),
+ ShopeeItemID: fmt.Sprintf("%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess,
+ RawJSON: `{"variationSku":"档口-x"}`,
+ }
+ if err := db.Create(&row).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ s := NewService(db)
+ preview, err := s.RecomputePreview(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if preview.PDDToExcluded != 25 {
+ t.Fatalf("expected 25 counted changes, got %d", preview.PDDToExcluded)
+ }
+ if len(preview.Samples) != 20 {
+ t.Fatalf("expected samples capped at 20, got %d", len(preview.Samples))
+ }
+}
diff --git a/web/src/api/goauto/syb-product-filters.js b/web/src/api/goauto/syb-product-filters.js
index 0cc607b..92936e0 100644
--- a/web/src/api/goauto/syb-product-filters.js
+++ b/web/src/api/goauto/syb-product-filters.js
@@ -4,3 +4,5 @@ export function listSybProductFilters(params) { return request({ url: base, meth
export function createSybProductFilter(data) { return request({ url: base, method: 'post', data }) }
export function setSybProductFilterEnabled(id, data) { return request({ url: `${base}/${id}/enabled`, method: 'patch', data }) }
export function deleteSybProductFilter(id) { return request({ url: `${base}/${id}`, method: 'delete' }) }
+export function previewSybProductFilterRecompute() { return request({ url: `${base}/recompute/preview`, method: 'get' }) }
+export function executeSybProductFilterRecompute() { return request({ url: `${base}/recompute/execute`, method: 'post' }) }
diff --git a/web/src/views/goauto/syb-product-filters/index.vue b/web/src/views/goauto/syb-product-filters/index.vue
index 7756b71..966bb3f 100644
--- a/web/src/views/goauto/syb-product-filters/index.vue
+++ b/web/src/views/goauto/syb-product-filters/index.vue
@@ -28,8 +28,15 @@
/>
variationSku 需要同时包含下方列出的每一个字符,才会被当成档口商品不入库;只包含其中一个的会正常入库。需要变更判据请另行建单评估。 variationSku 需要同时包含下方列出的每一个字符,才会被标记为无需 PDD 采购;只包含其中一个的仍按需要 PDD 采购处理。需要变更判据请另行建单评估。结构过滤(不可增删,仅可停用)
- 结构过滤(不可增删,仅可停用)
+
停用或删除只影响后续同步,已经入库的商品明细不会被删除。
+停用或删除规则只影响之后的同步;已经标记的商品明细不会自动变化,需要点击「按当前规则重算」才会按新规则重新判定。
按当前规则重算,没有需要变更的商品。
+ +变更示例(最多显示 20 条,实际变更数为 {{ recomputeTotalChanges }} 条):
+