From 00fb274fd4ffe62671ed8c3ae8c15b066e12223f Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 14:35:53 +0800 Subject: [PATCH 01/10] feat(syb): support 200/500 rows per page on SYB products list (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the syb-products page-size options to 20/50/100/200/500 with a new 100 default, cap the server-side sybimport.List page size at 500, chunk the per-page purchase-readiness preview into <=100-id requests, and add per-button selection limits (with disabled+tooltip) for AI 匹配, 创建采购, 创建采集, 图搜采集 and 匹配退货 so a larger page never silently exceeds a batch endpoint's cap. 创建采购's 100-item server cap is left untouched. Also caps returnmatch.BatchMatch at 500 ids (INVALID_REQUEST beyond that). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/returnmatch/handler.go | 4 ++ server/app/goauto/returnmatch/service.go | 13 +++++ server/app/goauto/returnmatch/service_test.go | 25 ++++++++++ server/app/goauto/sybimport/service.go | 4 +- server/app/goauto/sybimport/service_test.go | 30 ++++++++++++ web/src/views/goauto/syb-products/index.vue | 47 ++++++++++++++++--- 6 files changed, 114 insertions(+), 9 deletions(-) diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go index 1968c7e..80263ad 100644 --- a/server/app/goauto/returnmatch/handler.go +++ b/server/app/goauto/returnmatch/handler.go @@ -77,6 +77,10 @@ func (h Handler) BatchMatch(c *gin.Context) { } _, operator := operatorFromContext(c) resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator}) + if errors.Is(err, ErrTooManyItems) { + c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": ErrTooManyItems.Error()}) + return + } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"}) return diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index d12a1be..8b7d23c 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -68,10 +68,23 @@ const ( ReasonConflict = "conflict" ) +// maxBatchMatchItems caps a single「匹配退货」submission (#339: SYB list page +// size can now go up to 500/page, so the button's selection can exceed the +// previously-unbounded batch-match request size). +const maxBatchMatchItems = 500 + +// ErrTooManyItems is returned by BatchMatch when the caller submits more than +// maxBatchMatchItems SYB product ids; the handler turns this into an +// INVALID_REQUEST response instead of a 500. +var ErrTooManyItems = errors.New("sybProductIds 一次最多 500 条") + // BatchMatch implements issue #338's manual "匹配退货" trigger. It is only // ever called from the batch-match button (ticked rows) — no scheduler, no // yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务). func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) { + if len(req.SYBProductIDs) > maxBatchMatchItems { + return BatchMatchResponse{}, ErrTooManyItems + } resp, err := s.batchMatch(ctx, req) if len(req.SYBProductIDs) > 0 { // The batch record is written after the per-row transactions have diff --git a/server/app/goauto/returnmatch/service_test.go b/server/app/goauto/returnmatch/service_test.go index 00de4ca..0de5464 100644 --- a/server/app/goauto/returnmatch/service_test.go +++ b/server/app/goauto/returnmatch/service_test.go @@ -111,6 +111,31 @@ func TestBatchMatch_EndToEnd(t *testing.T) { } } +func TestBatchMatch_RejectsMoreThan500Items(t *testing.T) { + db := testDB(t) + s := NewService(db) + + ids := make([]uint64, maxBatchMatchItems+1) + for i := range ids { + ids[i] = uint64(i + 1) + } + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: ids, Operator: "tester"}) + if !errors.Is(err, ErrTooManyItems) { + t.Fatalf("expected ErrTooManyItems, got %v", err) + } + if len(resp.Items) != 0 { + t.Fatalf("expected empty response on rejection, got %+v", resp) + } + + var batchCount int64 + if err := db.Model(&models.ReturnMatchBatch{}).Count(&batchCount).Error; err != nil { + t.Fatal(err) + } + if batchCount != 0 { + t.Fatalf("rejected oversized batch must not be recorded, got %d rows", batchCount) + } +} + func TestBatchMatch_ExpiredDeadlineNotMatched(t *testing.T) { db := testDB(t) s := NewService(db) diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go index 2c9a1ff..480158c 100644 --- a/server/app/goauto/sybimport/service.go +++ b/server/app/goauto/sybimport/service.go @@ -65,8 +65,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp if request.PageSize < 1 { request.PageSize = 20 } - if request.PageSize > 100 { - request.PageSize = 100 + if request.PageSize > 500 { + request.PageSize = 500 } query := service.DB.WithContext(ctx).Model(&models.SYBProduct{}) if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" { diff --git a/server/app/goauto/sybimport/service_test.go b/server/app/goauto/sybimport/service_test.go index 2eede97..ca7c6e9 100644 --- a/server/app/goauto/sybimport/service_test.go +++ b/server/app/goauto/sybimport/service_test.go @@ -93,6 +93,36 @@ func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) { } } +func TestServiceListCapsPageSizeAt500(t *testing.T) { + db := openTestDB(t) + order := realOrder() + for i := 0; i < 3; i++ { + detail := realDetailA() + detail.ID += uint64(i) + order.Code = fmt.Sprintf("260728TB95MJTQ-%d", i) + if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil { + t.Fatalf("apply detail %d: %v", i, err) + } + } + service := sybimport.NewService(db) + + overLimit, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 600}) + if err != nil { + t.Fatalf("list with oversized page size: %v", err) + } + if overLimit.PageSize != 500 { + t.Fatalf("expected page size capped at 500, got %d", overLimit.PageSize) + } + + within, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 500}) + if err != nil { + t.Fatalf("list at exactly 500: %v", err) + } + if within.PageSize != 500 { + t.Fatalf("expected page size of exactly 500 to pass through unchanged, got %d", within.PageSize) + } +} + func TestServiceListRejectsInvalidParseStatus(t *testing.T) { db := openTestDB(t) service := sybimport.NewService(db) diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index af537fa..4ffabf8 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -12,7 +12,7 @@ - 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }} + 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }} @@ -73,7 +73,7 @@ - + @@ -329,6 +329,12 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue' // 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。 const IMAGE_SEARCH_MAX_BATCH_TASKS = 50 +// #339: 各批量按钮的服务端上限,用于勾选数超限时禁用按钮并提示原因。 +// 创建采购 / AI 匹配:purchase/batch.go maxBatchPurchaseItems;创建采集(去重后 PDD 商品数): +// task/admin_service.go BatchCreate 的 100 上限;匹配退货:returnmatch/service.go maxBatchMatchItems。 +const PURCHASE_BATCH_MAX = 100 +const COLLECTION_BATCH_MAX = 100 +const RETURN_MATCH_BATCH_MAX = 500 export default { name: 'GoAutoSybProducts', @@ -349,7 +355,7 @@ export default { imageSearchBatch: this.emptyImageSearchBatch(), collectionBatchData: { ruleId: null, deviceId: null }, collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] }, - query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }, + query: { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }, returnMatchByProductId: {}, returnMatchLoading: false, returnMatchBatchLoading: false, @@ -368,20 +374,34 @@ export default { processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] }, returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) }, aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) }, + aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX }, aiMatchButtonReason() { if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格' if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细' + if (this.aiMatchButtonOverLimit) return `一次最多 ${PURCHASE_BATCH_MAX} 条` if (this.aiMatchCandidates.length) return '' const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))] return reasons[0] || '所选明细不满足 AI 匹配前提' }, purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) }, + // #339: 创建采购上限保持 100(服务端 maxBatchPurchaseItems 不放宽),超出勾选数时禁用并提示。 + purchaseButtonOverLimit() { return this.purchaseCandidates.length > PURCHASE_BATCH_MAX }, + purchaseButtonReason() { return this.purchaseButtonOverLimit ? `一次最多 ${PURCHASE_BATCH_MAX} 条` : '' }, collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) }, collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] }, + // #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。 + collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX }, + collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' }, imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) }, // 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks), // 这里用相同口径预估,避免提交后才发现超限。 imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS }, + imageSearchButtonTaskCount() { return new Set(this.imageSearchRows.map(row => row.shopeeProductId)).size }, + imageSearchButtonOverLimit() { return this.imageSearchButtonTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS }, + imageSearchButtonReason() { return this.imageSearchButtonOverLimit ? `一次最多 ${IMAGE_SEARCH_MAX_BATCH_TASKS} 个蝦皮商品` : '' }, + // #339: 匹配退货服务端上限(returnmatch.maxBatchMatchItems),超出直接勾选数即禁用。 + returnMatchButtonOverLimit() { return this.returnMatchCandidateIds.length > RETURN_MATCH_BATCH_MAX }, + returnMatchButtonReason() { return this.returnMatchButtonOverLimit ? `一次最多 ${RETURN_MATCH_BATCH_MAX} 条` : '' }, imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size }, imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS }, imageSearchBatchDurationText() { @@ -428,7 +448,7 @@ export default { } }, search() { this.query.page = 1; this.load() }, - reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() }, + reset() { this.query = { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() }, normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() }, async ensureShopOptions() { if (this.shopOptionsLoaded) return @@ -473,16 +493,29 @@ export default { purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` }, processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } }, purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' }, + // #339: the page can now show up to 500 rows, but the preview endpoint + // (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still + // caps a single request at 100 ids, so this splits the current page into + // sequential chunks of <=100 and merges their results. Any chunk failing + // falls back to the same page-wide failure state the single-request path + // used before, so rows never end up in a partially-updated mix of real + // and failed readiness. async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) { if (generation !== this.loadGeneration) return const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id)) this.purchaseReadiness = {} if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return } this.purchaseReadinessLoading = true + const chunkSize = 100 + const merged = {} try { - const r = await previewPurchaseTasks({ sybProductIds: ids }, { ...requestOptions, suppressErrorMessage: true }) - if (generation !== this.loadGeneration) return - this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item])) + for (let start = 0; start < ids.length; start += chunkSize) { + const chunk = ids.slice(start, start + chunkSize) + const r = await previewPurchaseTasks({ sybProductIds: chunk }, { ...requestOptions, suppressErrorMessage: true }) + if (generation !== this.loadGeneration) return + for (const item of r.data.items) merged[item.sybProductId] = item + } + this.purchaseReadiness = merged await this.$nextTick() const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row)) this.$refs.productTable?.clearSelection() From 8b4db9c89e733066826487e76fb41b322a9f92ae Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 14:39:47 +0800 Subject: [PATCH 02/10] style(web): narrow SYB order-code filter input to 80% width (#339) 210px -> 168px, requested while trying the 500-rows page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- web/src/views/goauto/syb-products/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 4ffabf8..c6660da 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -853,7 +853,7 @@ export default { .search-form{display:flex;flex-wrap:nowrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc} .search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px} .search-form :deep(.el-form-item__content){flex-wrap:nowrap} -.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px} +.shop-input{width:130px}.order-codes-input{width:168px}.unlinked-tag{margin-left:8px} .shop-option{display:flex;align-items:center;justify-content:space-between;gap:12px} .shop-option-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .toolbar-actions :deep(.el-form-item__content){gap:8px} From 30c6b2ab50c4ad73940827856bf097aed351b0a7 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 24 Sep 2026 17:06:43 +0800 Subject: [PATCH 03/10] feat(syb): hard-isolate pdd_purchase_excluded rows from PDD flows (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SYB rows that hit a product filter rule are no longer skipped at import; they are stored with a pdd_purchase_excluded mark (rule snapshot fixed at row-creation time, never changed by a later resync), get a new pdd_excluded process stage ("无需采购"), are hard-rejected by every PDD entry point (purchase create/batch create/batch preview, collection and AI-match eligibility, image search batch collection, quick-replace via the new stage), and can still participate in #338 return matching. Adds an admin-only recompute preview/execute pair in sybproductfilter to re-derive marks against the current rules, skipping any row with a purchase task or an active return match, with an audit log row per run. sybimport.List gets a purchaseType filter (pdd/excluded/all, AND with processStage). Migration: cmd/migrate/migration/version-local/1789801100000 (additive columns/table, all existing rows default to "needs PDD purchase"). Web UI is out of scope, pending prototype approval. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/migrations/migrate.go | 1 + server/app/goauto/models/schema.go | 33 ++++ .../goauto/purchase/ai_match_eligibility.go | 3 + server/app/goauto/purchase/batch.go | 8 + .../goauto/purchase/collection_eligibility.go | 4 + .../purchase/pdd_excluded_block_test.go | 152 ++++++++++++++++ server/app/goauto/purchase/process_stage.go | 15 +- .../app/goauto/purchase/return_match_block.go | 11 ++ server/app/goauto/purchase/service.go | 3 + server/app/goauto/purchase/types.go | 9 +- .../goauto/returnmatch/pdd_excluded_test.go | 33 ++++ server/app/goauto/returnmatch/service.go | 6 + server/app/goauto/sybimport/apply.go | 33 ++++ server/app/goauto/sybimport/handler.go | 2 +- .../sybimport/pdd_exclusion_list_test.go | 71 ++++++++ .../goauto/sybimport/pdd_exclusion_test.go | 104 +++++++++++ server/app/goauto/sybimport/service.go | 22 +++ server/app/goauto/sybimport/sync.go | 23 ++- server/app/goauto/sybimport/sync_test.go | 66 ++++++- server/app/goauto/sybproductfilter/handler.go | 31 ++++ .../app/goauto/sybproductfilter/recompute.go | 163 ++++++++++++++++++ .../goauto/sybproductfilter/recompute_test.go | 107 ++++++++++++ server/app/goauto/sybproductfilter/router.go | 2 + server/app/goauto/task/image_search.go | 5 + server/app/goauto/task/pdd_excluded_test.go | 44 +++++ ...1789801100000_syb_pdd_purchase_excluded.go | 26 +++ 26 files changed, 967 insertions(+), 10 deletions(-) create mode 100644 server/app/goauto/purchase/pdd_excluded_block_test.go create mode 100644 server/app/goauto/returnmatch/pdd_excluded_test.go create mode 100644 server/app/goauto/sybimport/pdd_exclusion_list_test.go create mode 100644 server/app/goauto/sybimport/pdd_exclusion_test.go create mode 100644 server/app/goauto/sybproductfilter/recompute.go create mode 100644 server/app/goauto/sybproductfilter/recompute_test.go create mode 100644 server/app/goauto/task/pdd_excluded_test.go create mode 100644 server/cmd/migrate/migration/version-local/1789801100000_syb_pdd_purchase_excluded.go diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 443a094..bec32d5 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -43,6 +43,7 @@ func MigratedModels() []any { &models.SYBSession{}, &models.SYBShop{}, &models.SYBProductFilter{}, + &models.SYBProductFilterRecomputeLog{}, &models.SYBSyncRun{}, &models.YeekeSession{}, &models.YeekeReturnPackage{}, diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index fa881f4..0b55186 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -568,12 +568,45 @@ type SYBProduct struct { // parse-rule change, only the derived fields above may change. RawJSON string `json:"-" gorm:"type:json;not null"` + // PDDExcluded marks that this row hit an enabled product filter + // rule (char/keyword) at the moment it was first created by the sync + // (#340). It replaces the old "skip on filter hit" behaviour: a filtered + // row is still stored, but every PDD purchase/collection/AI-match entry + // point must hard-reject it. The mark is decided once, at creation time + // (sybimport.applyStockDetail / ApplyDetail); a later sync of the SAME + // existing row never changes it, even if the enabled rules changed in + // the meantime — only the admin "按当前规则重新计算" action + // (sybproductfilter recompute) may flip it, and only for rows with no + // purchase task and no active return match. + PDDExcluded bool `json:"pddPurchaseExcluded" gorm:"column:pdd_purchase_excluded;not null;default:false;index"` + // ExcludedRuleID/Kind/Keyword are a snapshot of the rule that matched at + // mark time, kept even if the rule is later edited or deleted, so the + // mark stays explainable in the UI and in return matching (#340). + ExcludedRuleID *uint64 `json:"excludedRuleId,omitempty"` + ExcludedRuleKind string `json:"excludedRuleKind,omitempty" gorm:"size:16;not null;default:''"` + ExcludedRuleKeyword string `json:"excludedRuleKeyword,omitempty" gorm:"size:200;not null;default:''"` + ExcludedAt *time.Time `json:"excludedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } func (SYBProduct) TableName() string { return "syb_product" } +// SYBProductFilterRecomputeLog audits the admin-only "按当前规则重新计算" +// action (#340 decision 7). Every execute run writes exactly one row here. +type SYBProductFilterRecomputeLog struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Operator string `json:"operator" gorm:"size:128;not null;default:''"` + ExcludedToPDD int `json:"excludedToPdd" gorm:"not null;default:0"` + PDDToExcluded int `json:"pddToExcluded" gorm:"not null;default:0"` + SkippedHasTask int `json:"skippedHasTask" gorm:"not null;default:0"` + SkippedReturnMatch int `json:"skippedReturnMatch" gorm:"not null;default:0"` + CreatedAt time.Time `json:"createdAt"` +} + +func (SYBProductFilterRecomputeLog) TableName() string { return "syb_product_filter_recompute_log" } + func (SYBSession) TableName() string { return "syb_session" } // SYBShop is the list of SYB shops whose shipment orders are imported (#49). diff --git a/server/app/goauto/purchase/ai_match_eligibility.go b/server/app/goauto/purchase/ai_match_eligibility.go index add170d..3f08971 100644 --- a/server/app/goauto/purchase/ai_match_eligibility.go +++ b/server/app/goauto/purchase/ai_match_eligibility.go @@ -131,6 +131,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa if !found { return disabled("SYB 商品不存在或已删除") } + if syb.PDDExcluded { + return disabled("该商品已标记为无需 PDD 采购") + } if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" { return disabled("未解析出需要采购的颜色或尺码") } diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 67f631d..f377041 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -399,6 +399,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua } item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity + // #340: hard-exclude before anything else in the pipeline. This preview + // is the single computation shared by BatchPreview, BatchCreate's + // pre-check and quick-replace's ValidateQuickReplacement, so this one + // check covers all of them. + if syb.PDDExcluded { + item.ReasonCode, item.Reason, item.NextAction = CodePDDExcluded, "该商品已标记为无需 PDD 采购", "" + return item + } if !sybSpecsTrusted(syb) { item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse" if syb.ParseStatus == models.SYBParseStatusUncertain { diff --git a/server/app/goauto/purchase/collection_eligibility.go b/server/app/goauto/purchase/collection_eligibility.go index fe270f8..6c53b87 100644 --- a/server/app/goauto/purchase/collection_eligibility.go +++ b/server/app/goauto/purchase/collection_eligibility.go @@ -9,6 +9,10 @@ func (item *BatchPreviewItem) applyCollectionEligibility(id uint64, dataset batc item.CollectionDisabledReason = "SYB 商品不存在或已删除" return } + if syb.PDDExcluded { + item.CollectionDisabledReason = "该商品已标记为无需 PDD 采购" + return + } if syb.ShopeeProductID == nil { item.CollectionDisabledReason = "尚未关联蝦皮商品" return diff --git a/server/app/goauto/purchase/pdd_excluded_block_test.go b/server/app/goauto/purchase/pdd_excluded_block_test.go new file mode 100644 index 0000000..fa82fc0 --- /dev/null +++ b/server/app/goauto/purchase/pdd_excluded_block_test.go @@ -0,0 +1,152 @@ +package purchase + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" +) + +// #340: a SYB product marked pdd_purchase_excluded must be hard-rejected by +// every PDD entry point, mirroring #338's return-match block tests above. + +func TestCreate_RejectsWhenPDDExcluded(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID). + Update("pdd_purchase_excluded", true).Error; err != nil { + t.Fatal(err) + } + + _, err := createLive(t, s, f) + if err == nil { + t.Fatalf("expected rejection, got success") + } + if se, ok := asServiceError(err); !ok || se.Code != CodePDDExcluded { + t.Fatalf("expected CodePDDExcluded, got %v", err) + } +} + +func TestBatchPreview_ExcludedRowReportsNotEligible(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID). + Update("pdd_purchase_excluded", true).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID}) + if err != nil { + t.Fatalf("preview call itself must not fail: %v", err) + } + if len(resp.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(resp.Items)) + } + item := resp.Items[0] + if item.Eligible { + t.Fatalf("excluded row must not be eligible: %+v", item) + } + if item.CollectionEligible { + t.Fatalf("excluded row must not be collection-eligible: %+v", item) + } + if item.AIMatchEligible { + t.Fatalf("excluded row must not be AI-match-eligible: %+v", item) + } + if item.ReasonCode != CodePDDExcluded { + t.Fatalf("expected CodePDDExcluded reason, got %+v", item) + } + if item.ProcessStage != ProcessStagePDDExcluded { + t.Fatalf("expected pdd_excluded stage, got %+v", item) + } +} + +func TestBatchCreate_SkipsExcludedRowNotWholeBatch(t *testing.T) { + db := testDB(t) + s := testService(db) + f1 := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f1.pdd.ID) + + syb2 := models.SYBProduct{OrderCode: "SYB-EXCL-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`} + if err := db.Create(&syb2).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f1.syb.ID). + Update("pdd_purchase_excluded", true).Error; err != nil { + t.Fatal(err) + } + + resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{ + RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID, + }) + if err != nil { + t.Fatalf("batch call itself must not fail: %v", err) + } + if resp.CreatedCount != 1 || resp.FailedCount != 1 { + t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items) + } + for _, item := range resp.Items { + if item.SYBProductID == f1.syb.ID && item.Created { + t.Fatalf("excluded row must not be created: %+v", item) + } + if item.SYBProductID == syb2.ID && !item.Created { + t.Fatalf("clean row must still be created: %+v", item) + } + } +} + +// TestProcessStage_ExcludedRowStage covers priority: active return match wins +// over the exclusion mark; once the match is cancelled the stage falls back +// to pdd_excluded (#340 decision 3). +func TestProcessStage_ExcludedRowStagePriority(t *testing.T) { + db := testDB(t) + s := testService(db) + f := seed(t, db, liveCaps(), true) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID). + Update("pdd_purchase_excluded", true).Error; err != nil { + t.Fatal(err) + } + + stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID}) + if err != nil { + t.Fatal(err) + } + if stages[f.syb.ID].Stage != ProcessStagePDDExcluded { + t.Fatalf("expected pdd_excluded, got %+v", stages[f.syb.ID]) + } + + match := models.ReturnMatch{ + SYBProductID: f.syb.ID, YeekeReturnItemID: 1, + ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched, + MatchedAt: time.Now(), + } + if err := db.Create(&match).Error; err != nil { + t.Fatal(err) + } + stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID}) + if err != nil { + t.Fatal(err) + } + if stages[f.syb.ID].Stage != ProcessStageReturnPending { + t.Fatalf("active return match must win over exclusion mark, got %+v", stages[f.syb.ID]) + } + + // Cancel: no more active match -> falls back to pdd_excluded, not the + // normal pipeline. + if err := db.Model(&models.ReturnMatch{}).Where("id = ?", match.ID). + Updates(map[string]any{"active_syb_product_id": nil, "status": models.ReturnMatchStatusCancelled}).Error; err != nil { + t.Fatal(err) + } + stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID}) + if err != nil { + t.Fatal(err) + } + if stages[f.syb.ID].Stage != ProcessStagePDDExcluded { + t.Fatalf("after cancel, stage should return to pdd_excluded, got %+v", stages[f.syb.ID]) + } +} diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index bcd668f..4f6713e 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -27,7 +27,12 @@ const ( // to (see rejectIfActiveReturnMatch and processStageFromDataset below). ProcessStageReturnPending = "return_pending" ProcessStageReturnUsed = "return_used" - processActionOpenPDDLink = "open_pdd_link" + // ProcessStagePDDExcluded is #340's stage for a SYB product marked + // pdd_purchase_excluded: it never needs a PDD purchase. It only yields to + // an active return match (checked first, same priority order #338 uses + // for the pipeline below it) — see processStageFromDataset. + ProcessStagePDDExcluded = "pdd_excluded" + processActionOpenPDDLink = "open_pdd_link" ) var processStageLabels = map[string]string{ @@ -43,6 +48,7 @@ var processStageLabels = map[string]string{ ProcessStageOrderReview: "待人工核对", ProcessStageReturnPending: "退货待确认", ProcessStageReturnUsed: "已用退货", + ProcessStagePDDExcluded: "无需采购", } type ProcessStageResult struct { @@ -151,6 +157,13 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match") } + // #340: an excluded row's mark also only overrides after the active-task + // and active-return-match checks above, and before every other pipeline + // branch below. + if syb, ok := dataset.sybByID[id]; ok && syb.PDDExcluded { + return stage(ProcessStagePDDExcluded, "顺云宝导入时已按过滤规则标记为无需 PDD 采购", "") + } + syb, ok := dataset.sybByID[id] if !ok { return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh") diff --git a/server/app/goauto/purchase/return_match_block.go b/server/app/goauto/purchase/return_match_block.go index 812375a..f122106 100644 --- a/server/app/goauto/purchase/return_match_block.go +++ b/server/app/goauto/purchase/return_match_block.go @@ -28,3 +28,14 @@ func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error { } return internal(err) } + +// rejectIfPDDExcluded implements #340's hard purchase-creation block: a SYB +// product marked pdd_purchase_excluded must never get a purchase task, +// single or batch (BatchCreate calls s.Create per row, so this one check +// point covers both, mirroring rejectIfActiveReturnMatch above). +func rejectIfPDDExcluded(syb models.SYBProduct) error { + if syb.PDDExcluded { + return fail(CodePDDExcluded, "该商品已标记为无需 PDD 采购,不能创建采购任务") + } + return nil +} diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index fb25908..6591b8c 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -181,6 +181,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas if err := rejectIfActiveReturnMatch(tx, syb.ID); err != nil { return err } + if err := rejectIfPDDExcluded(syb); err != nil { + return err + } if syb.ShopeeProductID == nil { return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品") } diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 332e71e..a452ced 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -41,7 +41,14 @@ const ( // tasks for such a product until the match is cancelled. Resuming or // reparsing an existing task is not affected. CodeReturnMatched = "PURCHASE_RETURN_MATCHED" - CodeInternal = "INTERNAL_ERROR" + // CodePDDExcluded is returned by every PDD entry point (purchase create, + // collection task creation, AI spec match, image search collection, + // quick-replace, ...) when the SYB product is marked + // pdd_purchase_excluded (#340): it hit a product filter rule at import + // time and is recorded as 「无需 PDD 采购」, so it must never start any + // PDD flow, regardless of the rest of its pipeline state. + CodePDDExcluded = "PURCHASE_PDD_EXCLUDED" + CodeInternal = "INTERNAL_ERROR" ) type ServiceError struct { diff --git a/server/app/goauto/returnmatch/pdd_excluded_test.go b/server/app/goauto/returnmatch/pdd_excluded_test.go new file mode 100644 index 0000000..a553d73 --- /dev/null +++ b/server/app/goauto/returnmatch/pdd_excluded_test.go @@ -0,0 +1,33 @@ +package returnmatch + +import ( + "context" + "testing" + "time" + + "go-admin/app/goauto/models" +) + +// #340: a pdd_purchase_excluded SYB product still needs no PDD purchase but +// must remain eligible to take part in return matching. +func TestBatchMatch_ExcludedStageParticipates(t *testing.T) { + db := testDB(t) + s := NewService(db) + s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) } + + deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + syb := seedSYB(t, db, "SYB-EXCL-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID). + Update("pdd_purchase_excluded", true).Error; err != nil { + t.Fatal(err) + } + seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline) + + resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"}) + if err != nil { + t.Fatal(err) + } + if resp.MatchedCount != 1 || len(resp.Items) != 1 || !resp.Items[0].Matched { + t.Fatalf("excluded-stage row must still be eligible for return matching: %+v", resp) + } +} diff --git a/server/app/goauto/returnmatch/service.go b/server/app/goauto/returnmatch/service.go index 2b3f26c..a945c2c 100644 --- a/server/app/goauto/returnmatch/service.go +++ b/server/app/goauto/returnmatch/service.go @@ -29,6 +29,12 @@ var participatingStages = map[string]bool{ purchase.ProcessStagePDDCollectionFail: true, purchase.ProcessStageColorMapping: true, purchase.ProcessStagePurchaseReady: true, + // #340: a pdd_purchase_excluded row still needs no PDD purchase and can + // still take part in return matching — matching it just confirms there is + // nothing left to buy back. After a cancel it returns to pdd_excluded, + // which stays true here (see processStageFromDataset), so the row is + // eligible again the same way any other participating stage is. + purchase.ProcessStagePDDExcluded: true, } type Service struct { diff --git a/server/app/goauto/sybimport/apply.go b/server/app/goauto/sybimport/apply.go index 7acf7ab..11d0168 100644 --- a/server/app/goauto/sybimport/apply.go +++ b/server/app/goauto/sybimport/apply.go @@ -8,6 +8,7 @@ import ( "math" "strconv" "strings" + "time" "go-admin/app/goauto/models" "go-admin/app/goauto/shopeeproduct" @@ -41,6 +42,18 @@ type DetailInput struct { ProductTitle string ProductThumb uint64 Raw json.RawMessage + + // Excluded and the ExcludedRule* fields are #340's product-filter mark: + // Excluded reports whether this line hit an enabled filter rule during + // THIS sync's matching, and the ExcludedRule* fields are a snapshot of + // that rule. They are only applied when ApplyDetail is about to CREATE a + // new syb_product row (decision 4: a later sync of an existing row must + // never change its existing mark, even if the rules or the match outcome + // changed since). + Excluded bool + ExcludedRuleID *uint64 + ExcludedRuleKind string + ExcludedRuleKeyword string } // ApplyResult reports what ApplyDetail actually did, for the import-result @@ -138,12 +151,32 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta err = tx.Where("order_code = ? AND detail_id = ?", order.Code, detail.ID).First(&existing).Error switch { case errors.Is(err, gorm.ErrRecordNotFound): + // #340 decision 4: the mark is set only when the row is first + // created, from this sync's own filter match — never on update. + if detail.Excluded { + now := time.Now().UTC() + record.PDDExcluded = true + record.ExcludedRuleID = detail.ExcludedRuleID + record.ExcludedRuleKind = detail.ExcludedRuleKind + record.ExcludedRuleKeyword = detail.ExcludedRuleKeyword + record.ExcludedAt = &now + } if err := tx.Create(&record).Error; err != nil { return err } result.Outcome = OutcomeCreated case err == nil: record.ID = existing.ID + // #340 decision 4: the exclusion mark is decided once, at row + // creation. A resync of an already-existing row must never + // change it (nor is it included in `updates` below), but the + // returned/in-memory record must still reflect the existing + // mark rather than the zero value this fresh struct starts with. + record.PDDExcluded = existing.PDDExcluded + record.ExcludedRuleID = existing.ExcludedRuleID + record.ExcludedRuleKind = existing.ExcludedRuleKind + record.ExcludedRuleKeyword = existing.ExcludedRuleKeyword + record.ExcludedAt = existing.ExcludedAt // Human-confirmed target values are authoritative and survive every // source re-import. ParseStatus/ParseNote below still record what the // current deterministic parser observed for audit. diff --git a/server/app/goauto/sybimport/handler.go b/server/app/goauto/sybimport/handler.go index 7d0b823..58e5e33 100644 --- a/server/app/goauto/sybimport/handler.go +++ b/server/app/goauto/sybimport/handler.go @@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) { return } response, err := service.List(c.Request.Context(), ListRequest{ - Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), + Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")), }) if err != nil { writeError(c, err) diff --git a/server/app/goauto/sybimport/pdd_exclusion_list_test.go b/server/app/goauto/sybimport/pdd_exclusion_list_test.go new file mode 100644 index 0000000..6c02d81 --- /dev/null +++ b/server/app/goauto/sybimport/pdd_exclusion_list_test.go @@ -0,0 +1,71 @@ +package sybimport_test + +import ( + "context" + "testing" + + "go-admin/app/goauto/sybimport" +) + +// #340: List's purchaseType filter combines with processStage as AND. +func TestServiceListPurchaseType(t *testing.T) { + db := openTestDB(t) + pdd, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA()) + if err != nil { + t.Fatalf("apply pdd row: %v", err) + } + excludedOrder := realOrder() + excludedOrder.Code = "260728EXCL" + excludedOrder.StockID++ + excludedDetail := realDetailB() + excludedDetail.ID++ + excludedDetail.Excluded = true + ruleID := uint64(1) + excludedDetail.ExcludedRuleID = &ruleID + excludedDetail.ExcludedRuleKind = "keyword" + excludedDetail.ExcludedRuleKeyword = "档口" + excluded, err := sybimport.ApplyDetail(context.Background(), db, excludedOrder, excludedDetail) + if err != nil { + t.Fatalf("apply excluded row: %v", err) + } + if !excluded.SYBProduct.PDDExcluded { + t.Fatalf("seed row was not marked excluded") + } + service := sybimport.NewService(db) + + defaultResp, err := service.List(context.Background(), sybimport.ListRequest{}) + if err != nil { + t.Fatal(err) + } + if defaultResp.Total != 1 || defaultResp.Items[0].ID != pdd.SYBProduct.ID { + t.Fatalf("default purchaseType must show only pdd rows: %+v", defaultResp) + } + + pddResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypePDD}) + if err != nil { + t.Fatal(err) + } + if pddResp.Total != 1 || pddResp.Items[0].ID != pdd.SYBProduct.ID { + t.Fatalf("explicit pdd purchaseType mismatch: %+v", pddResp) + } + + excludedResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypeExcluded}) + if err != nil { + t.Fatal(err) + } + if excludedResp.Total != 1 || excludedResp.Items[0].ID != excluded.SYBProduct.ID { + t.Fatalf("excluded purchaseType mismatch: %+v", excludedResp) + } + + allResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypeAll}) + if err != nil { + t.Fatal(err) + } + if allResp.Total != 2 { + t.Fatalf("all purchaseType must show both rows, got %+v", allResp) + } + + if _, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: "bogus"}); err == nil { + t.Fatalf("invalid purchaseType must be rejected") + } +} diff --git a/server/app/goauto/sybimport/pdd_exclusion_test.go b/server/app/goauto/sybimport/pdd_exclusion_test.go new file mode 100644 index 0000000..f8b0e2e --- /dev/null +++ b/server/app/goauto/sybimport/pdd_exclusion_test.go @@ -0,0 +1,104 @@ +package sybimport_test + +import ( + "context" + "encoding/json" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybimport" +) + +// #340: applyStockDetail no longer skips a filter-hit row; it stores it and +// marks it. These tests exercise the same public entry point (ApplyDetail) +// applyStockDetail calls, with the Excluded fields it now always passes in. + +func TestApplyDetailMarksExcludedRowOnCreate(t *testing.T) { + db := openTestDB(t) + order := sybimport.OrderInput{Code: "EXCL-ORDER", StockID: 1, ShopName: "测试店铺"} + ruleID := uint64(9) + raw, _ := json.Marshal(map[string]any{"variationSku": "档口-123"}) + result, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{ + ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t", + Raw: raw, + Excluded: true, + ExcludedRuleID: &ruleID, + ExcludedRuleKind: "keyword", + ExcludedRuleKeyword: "档口", + }) + if err != nil { + t.Fatalf("ApplyDetail: %v", err) + } + if !result.SYBProduct.PDDExcluded { + t.Fatalf("expected row to be marked excluded") + } + if result.SYBProduct.ExcludedRuleID == nil || *result.SYBProduct.ExcludedRuleID != ruleID { + t.Fatalf("expected excluded rule id snapshot %d, got %v", ruleID, result.SYBProduct.ExcludedRuleID) + } + if result.SYBProduct.ExcludedRuleKind != "keyword" || result.SYBProduct.ExcludedRuleKeyword != "档口" { + t.Fatalf("unexpected rule snapshot: %+v", result.SYBProduct) + } + if result.SYBProduct.ExcludedAt == nil { + t.Fatalf("expected excludedAt to be set") + } +} + +func TestApplyDetailNonHitRowIsNotMarked(t *testing.T) { + db := openTestDB(t) + order := sybimport.OrderInput{Code: "OK-ORDER", StockID: 1, ShopName: "测试店铺"} + raw, _ := json.Marshal(map[string]any{"variationSku": ""}) + result, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{ + ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t", + Raw: raw, + }) + if err != nil { + t.Fatalf("ApplyDetail: %v", err) + } + if result.SYBProduct.PDDExcluded { + t.Fatalf("expected row not to be marked excluded") + } + if result.SYBProduct.ExcludedRuleID != nil || result.SYBProduct.ExcludedAt != nil { + t.Fatalf("expected no rule snapshot on a non-hit row: %+v", result.SYBProduct) + } +} + +// TestApplyDetailResyncKeepsMarkEvenIfRulesChanged is decision 4: the mark +// is fixed at row-creation time. A later sync of the SAME existing row must +// not flip it even when it is re-applied with a different Excluded value +// (representing a rule that started/stopped matching since). +func TestApplyDetailResyncKeepsMarkEvenIfRulesChanged(t *testing.T) { + db := openTestDB(t) + order := sybimport.OrderInput{Code: "RESYNC-ORDER", StockID: 1, ShopName: "测试店铺"} + ruleID := uint64(1) + raw, _ := json.Marshal(map[string]any{"variationSku": "档口-123"}) + first, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{ + ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t", + Raw: raw, Excluded: true, ExcludedRuleID: &ruleID, ExcludedRuleKind: "keyword", ExcludedRuleKeyword: "档口", + }) + if err != nil || !first.SYBProduct.PDDExcluded { + t.Fatalf("seed create failed: %v %+v", err, first.SYBProduct) + } + + // Re-sync the same detail, this time with the rule disabled (no hit). + second, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{ + ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t", + Raw: raw, Excluded: false, + }) + if err != nil { + t.Fatalf("ApplyDetail update: %v", err) + } + if second.Outcome != sybimport.OutcomeUpdated { + t.Fatalf("expected update outcome, got %s", second.Outcome) + } + if !second.SYBProduct.PDDExcluded { + t.Fatalf("expected existing mark to be preserved across resync, got unmarked: %+v", second.SYBProduct) + } + + var stored models.SYBProduct + if err := db.Where("order_code = ? AND detail_id = ?", order.Code, uint64(1)).First(&stored).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if !stored.PDDExcluded || stored.ExcludedRuleKeyword != "档口" { + t.Fatalf("mark was changed by resync: %+v", stored) + } +} diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go index 480158c..00aefb0 100644 --- a/server/app/goauto/sybimport/service.go +++ b/server/app/goauto/sybimport/service.go @@ -44,8 +44,20 @@ type ListRequest struct { OrderCodes []string ParseStatus string ProcessStage string + // PurchaseType is #340's list-side isolation filter: "pdd" (default when + // empty) shows only rows that still need a PDD purchase, + // "excluded" shows only pdd_purchase_excluded rows, "all" shows both. It + // combines with ProcessStage as AND; the auto-switch to 全部 mentioned in + // the issue is a front-end behaviour, not a server default. + PurchaseType string } +const ( + PurchaseTypePDD = "pdd" + PurchaseTypeExcluded = "excluded" + PurchaseTypeAll = "all" +) + type ListResponse struct { Items []models.SYBProduct `json:"items"` Total int64 `json:"total"` @@ -92,6 +104,16 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp if !purchase.ValidProcessStage(request.ProcessStage) { return ListResponse{}, invalidRequest("processStage 无效") } + request.PurchaseType = strings.TrimSpace(request.PurchaseType) + switch request.PurchaseType { + case "", PurchaseTypePDD: + query = query.Where("pdd_purchase_excluded = ?", false) + case PurchaseTypeExcluded: + query = query.Where("pdd_purchase_excluded = ?", true) + case PurchaseTypeAll: + default: + return ListResponse{}, invalidRequest("purchaseType 无效") + } if request.ProcessStage != "" { var candidates []models.SYBProduct if err := query.Order("updated_at DESC, id DESC").Find(&candidates).Error; err != nil { diff --git a/server/app/goauto/sybimport/sync.go b/server/app/goauto/sybimport/sync.go index 3cd5a7d..d3523cc 100644 --- a/server/app/goauto/sybimport/sync.go +++ b/server/app/goauto/sybimport/sync.go @@ -395,12 +395,16 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, } for _, item := range detail.Details { variation := stringField(item.Raw, "variationSku") + // `[必须]` #340: a filter hit no longer skips the row. It is still + // stored, marked with a rule snapshot, and left to the hard PDD + // exclusion guards in purchase/task/returnmatch. Char/keyword hit + // counts and per-rule hits (#269) keep the same meaning — "marked", + // not "skipped" — so the disable-confirmation dialog's figures are + // still accurate. + var excluded bool + var excludedRuleID *uint64 + var excludedRuleKind, excludedRuleKeyword string 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 { @@ -409,7 +413,11 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, if report.filterHits != nil { report.filterHits.Add(rule) } - continue + excluded = true + ruleID := rule.ID + excludedRuleID = &ruleID + excludedRuleKind = rule.Kind + excludedRuleKeyword = rule.Keyword } raw, err := json.Marshal(item.Raw) if err != nil { @@ -424,6 +432,9 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, ProductTitle: item.ProductTitle, ProductThumb: uint64(item.ProductThumb), Raw: raw, + + Excluded: excluded, ExcludedRuleID: excludedRuleID, + ExcludedRuleKind: excludedRuleKind, ExcludedRuleKeyword: excludedRuleKeyword, }) if err != nil { return fmt.Errorf("写入货运单 %s 明细 %d 失败(本次同步停止;已写入的数据保留): %w", diff --git a/server/app/goauto/sybimport/sync_test.go b/server/app/goauto/sybimport/sync_test.go index dfd07ed..983c444 100644 --- a/server/app/goauto/sybimport/sync_test.go +++ b/server/app/goauto/sybimport/sync_test.go @@ -68,6 +68,9 @@ type fakeSYB struct { // 用于构造「列表说是 A 店、明细说是 B 店」的不一致。 detailShopName string blankDetailShopName bool + // detailVariationSku, when non-empty, is used as every detail's + // variationSku (#340 filter-marking tests). + detailVariationSku string } func (f *fakeSYB) shopFor(i int) string { @@ -142,7 +145,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server { "shopName": detailShopName, "details": []any{map[string]any{ "id": float64(id*10 + 1), "productId": float64(9001), - "productTitle": "测试商品", "productSpec": "白色,L", + "productTitle": "测试商品", "productSpec": "白色,L", "variationSku": f.detailVariationSku, "productQty": float64(2), "productPrice": 39.5, "productThumb": float64(77), }}, }) @@ -466,3 +469,64 @@ func TestSyncRejectsBlankShopOnDetailResponse(t *testing.T) { report.DetailCount, report.AcceptedCount, report.ShopSkipped) } } + +// #340: a filter hit no longer skips the row — it is still stored, counted +// (as "marked", same counters as before) and left for the hard PDD exclusion +// guards elsewhere in the pipeline to enforce. +func TestSyncStoresAndMarksFilterHitRowsInsteadOfSkipping(t *testing.T) { + db := newSyncTestDB(t) + if err := db.Create(&models.SYBProductFilter{ + Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true, + }).Error; err != nil { + t.Fatalf("seed filter: %v", err) + } + f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, detailVariationSku: "档口-99"} + + report, err := Sync(context.Background(), db, newSyncClient(t, f), + SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01") + if err != nil { + t.Fatalf("同步失败: %v", err) + } + if report.KeywordFilterSkipped != 2 { + t.Fatalf("expected 2 marked hits, got %d", report.KeywordFilterSkipped) + } + if report.DetailCount != 2 || report.Created != 2 { + t.Fatalf("filter hits must still be stored: detailCount=%d created=%d", report.DetailCount, report.Created) + } + var count int64 + db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count) + if count != 2 { + t.Fatalf("expected 2 rows marked excluded, got %d", count) + } + var filter models.SYBProductFilter + if err := db.Where("keyword = ?", "档口").First(&filter).Error; err != nil { + t.Fatalf("reload filter: %v", err) + } + if filter.LastHitCount == nil || *filter.LastHitCount != 2 { + t.Fatalf("expected rule's own hit count to be 2, got %v", filter.LastHitCount) + } +} + +func TestSyncNonHitRowsAreNotMarked(t *testing.T) { + db := newSyncTestDB(t) + if err := db.Create(&models.SYBProductFilter{ + Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true, + }).Error; err != nil { + t.Fatalf("seed filter: %v", err) + } + f := &fakeSYB{perDay: map[string]int{"2026-08-01": 1}, detailVariationSku: "普通-1"} + + report, err := Sync(context.Background(), db, newSyncClient(t, f), + SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01") + if err != nil { + t.Fatalf("同步失败: %v", err) + } + if report.KeywordFilterSkipped != 0 { + t.Fatalf("unexpected marked hits: %d", report.KeywordFilterSkipped) + } + var count int64 + db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count) + if count != 0 { + t.Fatalf("no rows should be marked excluded, got %d", count) + } +} diff --git a/server/app/goauto/sybproductfilter/handler.go b/server/app/goauto/sybproductfilter/handler.go index bb3bfbc..b5a58d7 100644 --- a/server/app/goauto/sybproductfilter/handler.go +++ b/server/app/goauto/sybproductfilter/handler.go @@ -77,6 +77,37 @@ func (h Handler) SetEnabled(c *gin.Context) { } c.JSON(200, gin.H{"code": 200, "data": x}) } + +// RecomputePreview and RecomputeExecute implement #340 decision 7's admin-only +// "按当前规则重新计算" action. Both are gated by middleware.RequireRoleKey +// ("admin") at the router, the same admin gate this package already uses for +// Create/SetEnabled/Delete. +func (h Handler) RecomputePreview(c *gin.Context) { + s, ok := h.service(c) + if !ok { + return + } + counts, e := s.RecomputePreview(c.Request.Context()) + if e != nil { + writeError(c, e) + return + } + c.JSON(200, gin.H{"code": 200, "data": counts}) +} +func (h Handler) RecomputeExecute(c *gin.Context) { + s, ok := h.service(c) + if !ok { + return + } + claims := jwt.ExtractClaims(c) + name, _ := claims["nice"].(string) + result, e := s.RecomputeExecute(c.Request.Context(), name) + if e != nil { + writeError(c, e) + return + } + c.JSON(200, gin.H{"code": 200, "data": result}) +} func (h Handler) Delete(c *gin.Context) { id, ok := idParam(c) if !ok { diff --git a/server/app/goauto/sybproductfilter/recompute.go b/server/app/goauto/sybproductfilter/recompute.go new file mode 100644 index 0000000..e2d4ece --- /dev/null +++ b/server/app/goauto/sybproductfilter/recompute.go @@ -0,0 +1,163 @@ +package sybproductfilter + +import ( + "context" + "encoding/json" + "strings" + "time" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +// RecomputeCounts is shared by preview and execute (#340 decision 7): the two +// must agree, so both are computed by recomputeChanges below. +type RecomputeCounts struct { + ExcludedToPDD int `json:"excludedToPdd"` + PDDToExcluded int `json:"pddToExcluded"` + SkippedHasTask int `json:"skippedHasTask"` + SkippedReturnMatch int `json:"skippedReturnMatch"` +} + +type recomputeChange struct { + id uint64 + toExcluded bool // true: pdd -> excluded; false: excluded -> pdd + ruleID *uint64 + ruleKind, ruleKeyword string +} + +// 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 +// change (#340 decision 7). Preview and execute both call this so they can +// never disagree. +func recomputeChanges(ctx context.Context, tx *gorm.DB) (RecomputeCounts, []recomputeChange, error) { + filters, err := LoadEnabled(ctx, tx) + if err != nil { + return RecomputeCounts{}, nil, err + } + var rows []models.SYBProduct + if err := tx.WithContext(ctx).Find(&rows).Error; err != nil { + return RecomputeCounts{}, nil, err + } + hasTask := make(map[uint64]bool) + var taskSYBIDs []uint64 + if err := tx.WithContext(ctx).Table("purchase_task").Distinct("syb_product_id"). + Where("syb_product_id IS NOT NULL").Pluck("syb_product_id", &taskSYBIDs).Error; err != nil { + return RecomputeCounts{}, nil, err + } + for _, id := range taskSYBIDs { + hasTask[id] = true + } + activeReturnMatch := make(map[uint64]bool) + var matchedSYBIDs []uint64 + if err := tx.WithContext(ctx).Table("return_match").Where("active_syb_product_id IS NOT NULL").Pluck("syb_product_id", &matchedSYBIDs).Error; err != nil { + return RecomputeCounts{}, nil, err + } + for _, id := range matchedSYBIDs { + activeReturnMatch[id] = true + } + + counts := RecomputeCounts{} + changes := make([]recomputeChange, 0) + for _, row := range rows { + variation := recomputeVariationSku(row.RawJSON) + rule := filters.Match(variation) + wouldExclude := rule != nil + if wouldExclude == row.PDDExcluded { + continue + } + if hasTask[row.ID] { + counts.SkippedHasTask++ + continue + } + if activeReturnMatch[row.ID] { + counts.SkippedReturnMatch++ + continue + } + change := recomputeChange{id: row.ID, toExcluded: wouldExclude} + if wouldExclude { + ruleID := rule.ID + change.ruleID, change.ruleKind, change.ruleKeyword = &ruleID, rule.Kind, rule.Keyword + counts.PDDToExcluded++ + } else { + counts.ExcludedToPDD++ + } + changes = append(changes, change) + } + return counts, changes, nil +} + +// recomputeVariationSku mirrors sybimport.stringField(item.Raw, "variationSku") +// without importing that package (sybimport already imports this one). +func recomputeVariationSku(rawJSON string) string { + if strings.TrimSpace(rawJSON) == "" { + return "" + } + var raw map[string]any + if json.Unmarshal([]byte(rawJSON), &raw) != nil { + return "" + } + value, _ := raw["variationSku"].(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) + if err != nil { + return RecomputeCounts{}, internal(err) + } + return counts, nil +} + +type RecomputeExecuteResult struct { + RecomputeCounts + Operator string `json:"operator"` +} + +// RecomputeExecute writes every eligible flip and one audit log row, all in +// one transaction so the log always matches what was actually changed. +func (s *Service) RecomputeExecute(ctx context.Context, operator string) (RecomputeExecuteResult, error) { + var result RecomputeExecuteResult + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + counts, changes, err := recomputeChanges(ctx, tx) + if err != nil { + return err + } + now := time.Now().UTC() + for _, change := range changes { + updates := map[string]any{"pdd_purchase_excluded": change.toExcluded} + if change.toExcluded { + updates["excluded_rule_id"] = change.ruleID + updates["excluded_rule_kind"] = change.ruleKind + updates["excluded_rule_keyword"] = change.ruleKeyword + updates["excluded_at"] = now + } else { + updates["excluded_rule_id"] = nil + updates["excluded_rule_kind"] = "" + updates["excluded_rule_keyword"] = "" + updates["excluded_at"] = nil + } + if err := tx.Model(&models.SYBProduct{}).Where("id = ?", change.id).Updates(updates).Error; err != nil { + return err + } + } + log := models.SYBProductFilterRecomputeLog{ + Operator: strings.TrimSpace(operator), ExcludedToPDD: counts.ExcludedToPDD, + PDDToExcluded: counts.PDDToExcluded, SkippedHasTask: counts.SkippedHasTask, + SkippedReturnMatch: counts.SkippedReturnMatch, + } + if err := tx.Create(&log).Error; err != nil { + return err + } + result = RecomputeExecuteResult{RecomputeCounts: counts, Operator: log.Operator} + return nil + }) + if err != nil { + return RecomputeExecuteResult{}, internal(err) + } + return result, nil +} diff --git a/server/app/goauto/sybproductfilter/recompute_test.go b/server/app/goauto/sybproductfilter/recompute_test.go new file mode 100644 index 0000000..f503d3a --- /dev/null +++ b/server/app/goauto/sybproductfilter/recompute_test.go @@ -0,0 +1,107 @@ +package sybproductfilter + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" +) + +func TestRecomputePreviewMatchesExecute(t *testing.T) { + db := testDB(t) + // A row that currently needs a PDD purchase but now matches a keyword rule. + pddToExcluded := models.SYBProduct{ + OrderCode: "ORD-1", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`, + } + if err := db.Create(&pddToExcluded).Error; err != nil { + t.Fatal(err) + } + // A row currently marked excluded whose rule no longer matches. + excludedToPDD := models.SYBProduct{ + OrderCode: "ORD-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"普通-2"}`, + PDDExcluded: true, + } + if err := db.Create(&excludedToPDD).Error; err != nil { + t.Fatal(err) + } + // A row that should flip, but has a purchase task -> must be skipped. + hasTask := models.SYBProduct{ + OrderCode: "ORD-3", DetailID: 3, StockID: 3, ShopeeItemID: "3", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-3"}`, + } + if err := db.Create(&hasTask).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.PurchaseTask{SYBProductID: &hasTask.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "req-3", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil { + t.Fatal(err) + } + // A row that should flip, but has an active return match -> must be skipped. + hasMatch := models.SYBProduct{ + OrderCode: "ORD-4", DetailID: 4, StockID: 4, ShopeeItemID: "4", Quantity: 1, + ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-4"}`, + } + if err := db.Create(&hasMatch).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.ReturnMatch{SYBProductID: hasMatch.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &hasMatch.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil { + t.Fatal(err) + } + + s := NewService(db) + preview, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + if preview.PDDToExcluded != 1 || preview.ExcludedToPDD != 1 || preview.SkippedHasTask != 1 || preview.SkippedReturnMatch != 1 { + t.Fatalf("unexpected preview counts: %+v", preview) + } + + result, err := s.RecomputeExecute(context.Background(), "admin1") + if err != nil { + t.Fatal(err) + } + if result.RecomputeCounts != preview { + t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview, result.RecomputeCounts) + } + + var reloadedPDDToExcluded, reloadedExcludedToPDD, reloadedHasTask, reloadedHasMatch models.SYBProduct + db.First(&reloadedPDDToExcluded, pddToExcluded.ID) + db.First(&reloadedExcludedToPDD, excludedToPDD.ID) + db.First(&reloadedHasTask, hasTask.ID) + db.First(&reloadedHasMatch, hasMatch.ID) + + if !reloadedPDDToExcluded.PDDExcluded || reloadedPDDToExcluded.ExcludedRuleKeyword != "档口" { + t.Fatalf("expected row 1 to become excluded: %+v", reloadedPDDToExcluded) + } + if reloadedExcludedToPDD.PDDExcluded || reloadedExcludedToPDD.ExcludedRuleID != nil { + t.Fatalf("expected row 2 to become un-excluded: %+v", reloadedExcludedToPDD) + } + if reloadedHasTask.PDDExcluded { + t.Fatalf("row with a purchase task must never change: %+v", reloadedHasTask) + } + if reloadedHasMatch.PDDExcluded { + t.Fatalf("row with an active return match must never change: %+v", reloadedHasMatch) + } + + var logs []models.SYBProductFilterRecomputeLog + if err := db.Find(&logs).Error; err != nil { + t.Fatal(err) + } + if len(logs) != 1 || logs[0].Operator != "admin1" || logs[0].PDDToExcluded != 1 || logs[0].ExcludedToPDD != 1 { + t.Fatalf("expected exactly one audit log row matching the counts: %+v", logs) + } + + // Preview and execute must still agree on a no-op run. + secondPreview, err := s.RecomputePreview(context.Background()) + if err != nil { + t.Fatal(err) + } + if secondPreview.PDDToExcluded != 0 || secondPreview.ExcludedToPDD != 0 { + t.Fatalf("expected a no-op second preview, got %+v", secondPreview) + } +} diff --git a/server/app/goauto/sybproductfilter/router.go b/server/app/goauto/sybproductfilter/router.go index 6a6c721..c6a34b9 100644 --- a/server/app/goauto/sybproductfilter/router.go +++ b/server/app/goauto/sybproductfilter/router.go @@ -12,4 +12,6 @@ func InitRouter(e *gin.Engine, a *jwt.GinJWTMiddleware) { g.POST("", middleware.RequireRoleKey("admin"), Handler{}.Create) g.PATCH("/:filterId/enabled", middleware.RequireRoleKey("admin"), Handler{}.SetEnabled) g.DELETE("/:filterId", middleware.RequireRoleKey("admin"), Handler{}.Delete) + g.GET("/recompute/preview", middleware.RequireRoleKey("admin"), Handler{}.RecomputePreview) + g.POST("/recompute/execute", middleware.RequireRoleKey("admin"), Handler{}.RecomputeExecute) } diff --git a/server/app/goauto/task/image_search.go b/server/app/goauto/task/image_search.go index a3a6e2f..a3419d7 100644 --- a/server/app/goauto/task/image_search.go +++ b/server/app/goauto/task/image_search.go @@ -158,6 +158,11 @@ func (service *Service) BatchCreateImageSearch(ctx context.Context, request Imag response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "SYB_PRODUCT_UNAVAILABLE", Message: "SYB 商品不存在或未关联蝦皮商品"}) continue } + // #340: hard-exclude before grouping/creating any image search task. + if row.PDDExcluded { + response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_PDD_EXCLUDED", Message: "该商品已标记为无需 PDD 采购"}) + continue + } if index, ok := groups[*row.ShopeeProductID]; ok { response.Items[index].SYBProductIDs = append(response.Items[index].SYBProductIDs, id) } else { diff --git a/server/app/goauto/task/pdd_excluded_test.go b/server/app/goauto/task/pdd_excluded_test.go new file mode 100644 index 0000000..3656c8f --- /dev/null +++ b/server/app/goauto/task/pdd_excluded_test.go @@ -0,0 +1,44 @@ +package task + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" + + "github.com/google/uuid" +) + +// #340: image search collection is a PDD entry point keyed by sybProductIds; +// an excluded row must be rejected without touching the batch's other rows. +func TestBatchCreateImageSearchRejectsExcludedRow(t *testing.T) { + db := openTaskDatabase(t) + rule := models.CollectionRule{Name: "image-search-excluded", ContentJSON: v2TaskRuleSnapshot()} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + shopee := models.ShopeeProduct{ShopeeItemID: "excl-1", Title: "t", Currency: "CNY", SpecsJSON: "[]"} + if err := db.Create(&shopee).Error; err != nil { + t.Fatal(err) + } + syb := models.SYBProduct{OrderCode: "ORD-EXCL", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/excl.jpg", ParseStatus: "success", RawJSON: "{}", PDDExcluded: true} + if err := db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + service := NewService(db) + service.FetchImageSearchImage = func(ctx context.Context, url string) (ImageSearchImage, error) { + t.Fatalf("must not fetch image for an excluded row") + return ImageSearchImage{}, nil + } + request := ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{syb.ID}, RuleID: rule.ID} + response, err := service.BatchCreateImageSearch(context.Background(), request) + if err != nil { + t.Fatalf("batch call itself must not fail: %v", err) + } + if response.SuccessCount != 0 || len(response.Items) != 1 { + t.Fatalf("expected the excluded row to fail, got %+v", response) + } + if response.Items[0].Code != "PURCHASE_PDD_EXCLUDED" { + t.Fatalf("expected PURCHASE_PDD_EXCLUDED, got %+v", response.Items[0]) + } +} diff --git a/server/cmd/migrate/migration/version-local/1789801100000_syb_pdd_purchase_excluded.go b/server/cmd/migrate/migration/version-local/1789801100000_syb_pdd_purchase_excluded.go new file mode 100644 index 0000000..5bdc73d --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1789801100000_syb_pdd_purchase_excluded.go @@ -0,0 +1,26 @@ +package version_local + +import ( + "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +// #340: adds syb_product's pdd_purchase_excluded/excluded_rule_*/excluded_at +// columns (additive, all default to "需 PDD 采购") and creates +// syb_product_filter_recompute_log (registered in migrations.MigratedModels) +// on databases whose earlier versions are already recorded in sys_migration. +func init() { + _, f, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(f), migrateSYBPDDPurchaseExcluded) +} +func migrateSYBPDDPurchaseExcluded(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := migrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} 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 04/10] =?UTF-8?q?feat(syb):=20#340=20phase=202=20=E2=80=94?= =?UTF-8?q?=20merge=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) }}