From 316d5aa412660db0925afbee9a99ea0cfedf2104 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 29 Sep 2026 10:59:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(syb):=20#340=20phase=202=20=E2=80=94=20mer?= =?UTF-8?q?ge=20main,=20recompute=20samples,=20frontend=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge origin/main (through #345/e7c049d) into feat/340-syb-excluded-products. Resolved conflicts in sybimport/service.go, handler.go, service_test.go (kept #342's createdFrom/createdTo AND #340's purchaseType, all combined with processStage), and took origin/main's syb-products/index.vue as the base for the new UI work below. Renamed the migration version file from 1789801100000 to 1789801500000 (next free slot after main's highest, 1789801400000) — content unchanged, version comes from the filename. Backend: sybproductfilter recompute preview now also returns up to 20 sample rows (order code, shopee item id, change direction, matched rule) alongside the existing counts; execute stays count + audit-log only. Frontend (feat/340 issue "## 设计证据"/"## 页面", prototype v1): - SYB 订单商品页: 采购类型 filter (需 PDD 采购 default / 无需 PDD 采购 / 全部), 处理阶段 gains 无需采购 (pdd_excluded); selecting 退货待确认/已用退货/无需采购 auto-switches 采购类型 to 全部; pdd_excluded rows are tickable only for 匹配退货 (never collection/purchase/AI-match/image-search); rows show the stage tag plus 规则: ; detail drawer shows 采购类型 and rule. - SYB 过滤规则页: 跳过导入 -> 标记为无需 PDD 采购 wording, 上次同步命中 -> 上次同步标记, rewritten scope note, admin-only 按当前规则重算 button with preview dialog (4 counts + up to 20 samples) -> confirm -> execute. - SYB 同步记录: 结构/关键词过滤命中 -> 结构/关键词过滤标记 (see #340 point 8; the run-level "其中无需采购 N 条" count is derivable client-side from the existing char/keyword counts, no backend field added). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- .../app/goauto/sybproductfilter/recompute.go | 57 ++++++- .../goauto/sybproductfilter/recompute_test.go | 56 ++++++- web/src/api/goauto/syb-product-filters.js | 2 + .../goauto/syb-product-filters/index.vue | 145 +++++++++++++++++- web/src/views/goauto/syb-products/index.vue | 40 +++-- 5 files changed, 274 insertions(+), 26 deletions(-) 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 采购处理。需要变更判据请另行建单评估。

@@ -47,7 +54,7 @@ {{ row.enabled ? '已启用' : '已停用' }} - + @@ -101,7 +108,7 @@ {{ row.enabled ? '已启用' : '已停用' }} - + @@ -116,7 +123,7 @@
-

停用或删除只影响后续同步,已经入库的商品明细不会被删除。

+

停用或删除规则只影响之后的同步;已经标记的商品明细不会自动变化,需要点击「按当前规则重算」才会按新规则重新判定。

@@ -140,6 +147,57 @@ 保存 + + +
+ + +
+ +
@@ -150,9 +208,15 @@ import { listSybProductFilters, createSybProductFilter, setSybProductFilterEnabled, - deleteSybProductFilter + deleteSybProductFilter, + previewSybProductFilterRecompute, + executeSybProductFilterRecompute } from '@/api/goauto/syb-product-filters' +function emptyRecomputeResult() { + return { pddToExcluded: 0, excludedToPdd: 0, skippedHasTask: 0, skippedReturnMatch: 0, samples: [] } +} + export default { name: 'GoAutoSybProductFilters', data() { @@ -161,12 +225,26 @@ export default { chars: [], keywords: [], query: { keyword: '' }, - editor: { open: false, saving: false, keyword: '', note: '' } + editor: { open: false, saving: false, keyword: '', note: '' }, + recompute: { + open: false, + loading: false, + executing: false, + loaded: false, + error: '', + result: emptyRecomputeResult() + } } }, computed: { isAdmin() { return (this.$store.getters.roles || []).includes('admin') + }, + recomputeButtonReason() { + return this.isAdmin ? '' : '只有管理员可以重算' + }, + recomputeTotalChanges() { + return this.recompute.result.pddToExcluded + this.recompute.result.excludedToPdd } }, created() { @@ -210,7 +288,7 @@ export default { async confirmDisableChar(row) { const scale = row.lastHitCount == null ? '尚不清楚有多少' : `约 ${row.lastHitCount} 条` await ElMessageBox.confirm( - `停用后,SYB 同步将不再过滤任何档口商品(结构过滤只有这一条)。按最近一次同步数据估算,${scale}明细会恢复入库。`, + `停用后,SYB 同步将不再把任何档口商品标记为无需 PDD 采购(结构过滤只有这一条)。这只影响之后的同步,${scale}已标记的明细不会自动变化,需要使用「按当前规则重算」才会更新。`, `确认停用结构过滤「${row.keyword}」?`, { type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' } ) @@ -267,6 +345,35 @@ export default { await deleteSybProductFilter(row.id) ElMessage.success('关键词已删除') this.load() + }, + resetRecompute() { + this.recompute = { open: false, loading: false, executing: false, loaded: false, error: '', result: emptyRecomputeResult() } + }, + async openRecompute() { + this.recompute.open = true + this.recompute.loading = true + this.recompute.loaded = false + this.recompute.error = '' + try { + const res = await previewSybProductFilterRecompute() + this.recompute.result = { ...emptyRecomputeResult(), ...(res.data || {}) } + this.recompute.loaded = true + } catch (e) { + this.recompute.error = (e && e.message) || '预览失败,请重试' + } finally { + this.recompute.loading = false + } + }, + async confirmRecompute() { + this.recompute.executing = true + try { + await executeSybProductFilterRecompute() + ElMessage.success('已按当前规则重新计算') + this.recompute.open = false + this.load() + } finally { + this.recompute.executing = false + } } } } @@ -312,4 +419,26 @@ h3 { .scope-note { margin-top: 12px; } +.recompute-counts { + display: flex; + gap: 16px; + margin-bottom: 16px; +} +.count-item { + flex: 1; + text-align: center; + padding: 12px; + background: #f5f7fa; + border-radius: 4px; +} +.count-value { + font-size: 22px; + font-weight: 600; + color: #303133; +} +.count-label { + font-size: 12px; + color: #909399; + margin-top: 4px; +} diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index d0e1517..cac047a 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -12,7 +12,8 @@ - + + 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }}取消匹配{{ returnCancelCandidates.length }}使用退货{{ returnConfirmCandidates.length }}
@@ -37,6 +38,7 @@ {{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}
{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}
{{ purchasePriceText(purchaseReady(row)) }}
+
规则:{{ excludedRuleText(row) }}