diff --git a/server/app/goauto/returnmatch/handler.go b/server/app/goauto/returnmatch/handler.go index eb16380..4f950b8 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 f20adec..2b3f26c 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/handler.go b/server/app/goauto/sybimport/handler.go index 7d0b823..2a8f417 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")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")), }) if err != nil { writeError(c, err) diff --git a/server/app/goauto/sybimport/service.go b/server/app/goauto/sybimport/service.go index 2c9a1ff..3fe2e00 100644 --- a/server/app/goauto/sybimport/service.go +++ b/server/app/goauto/sybimport/service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "time" "go-admin/app/goauto/models" "go-admin/app/goauto/purchase" @@ -44,6 +45,8 @@ type ListRequest struct { OrderCodes []string ParseStatus string ProcessStage string + CreatedFrom string + CreatedTo string } type ListResponse struct { @@ -65,10 +68,20 @@ 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{}) + createdFrom, createdTo, err := createdAtRange(request.CreatedFrom, request.CreatedTo) + if err != nil { + return ListResponse{}, err + } + if createdFrom != nil { + query = query.Where("created_at >= ?", *createdFrom) + } + if createdTo != nil { + query = query.Where("created_at < ?", *createdTo) + } if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" { if len([]rune(request.ShopName)) > 255 { return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符") @@ -142,6 +155,43 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil } +// createdAtRange turns inclusive YYYY-MM-DD bounds into a half-open time +// range. The bounds are interpreted in the server's local timezone, matching +// the timestamps written by GORM for this service. +func createdAtRange(from, to string) (*time.Time, *time.Time, error) { + from = strings.TrimSpace(from) + to = strings.TrimSpace(to) + if from == "" && to == "" { + return nil, nil, nil + } + parse := func(value, label string) (*time.Time, error) { + if value == "" { + return nil, nil + } + parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) + if err != nil { + return nil, invalidRequest(label + " 必须是 YYYY-MM-DD") + } + return &parsed, nil + } + start, err := parse(from, "createdFrom") + if err != nil { + return nil, nil, err + } + endDay, err := parse(to, "createdTo") + if err != nil { + return nil, nil, err + } + if start != nil && endDay != nil && start.After(*endDay) { + return nil, nil, invalidRequest("createdFrom 不能晚于 createdTo") + } + if endDay != nil { + end := endDay.AddDate(0, 0, 1) + endDay = &end + } + return start, endDay, nil +} + func normalizeOrderCodes(raw []string) ([]string, error) { seen := make(map[string]bool, len(raw)) result := make([]string, 0, len(raw)) diff --git a/server/app/goauto/sybimport/service_test.go b/server/app/goauto/sybimport/service_test.go index 2eede97..e104afc 100644 --- a/server/app/goauto/sybimport/service_test.go +++ b/server/app/goauto/sybimport/service_test.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" "testing" + "time" "go-admin/app/goauto/models" "go-admin/app/goauto/sybimport" @@ -93,6 +94,76 @@ 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 TestServiceListFiltersByCreatedDateInclusive(t *testing.T) { + db := openTestDB(t) + order := realOrder() + for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} { + rowOrder := order + rowOrder.Code = fmt.Sprintf("CREATED-%d", i) + rowOrder.StockID += uint64(i) + detail := realDetailA() + detail.ID += uint64(i) + result, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail) + if err != nil { + t.Fatal(err) + } + created, err := time.ParseInLocation("2006-01-02", day, time.Local) + if err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", created).Error; err != nil { + t.Fatal(err) + } + } + service := sybimport.NewService(db) + + between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"}) + if err != nil || between.Total != 2 { + t.Fatalf("inclusive created date range should return two rows, total=%d err=%v", between.Total, err) + } + fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-03"}) + if err != nil || fromOnly.Total != 1 { + t.Fatalf("created-from filter should return one row, total=%d err=%v", fromOnly.Total, err) + } + toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-01"}) + if err != nil || toOnly.Total != 1 { + t.Fatalf("created-to filter should return one row, total=%d err=%v", toOnly.Total, err) + } + if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-04", CreatedTo: "2026-09-01"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest { + t.Fatalf("reversed created date range should be rejected: %v", err) + } +} + 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 4230ca5..91fff83 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 }} + + 查询重置AI 匹配{{ aiMatchCandidates.length }}图搜采集{{ imageSearchRows.length }}创建采集{{ collectionCandidates.length }}创建采购{{ purchaseCandidates.length }}匹配退货{{ returnMatchCandidateIds.length }} @@ -75,7 +76,7 @@ - + @@ -333,6 +334,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', @@ -353,7 +360,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: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }, returnMatchByProductId: {}, returnMatchLoading: false, returnMatchBatchLoading: false, @@ -372,20 +379,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() { @@ -412,7 +433,8 @@ export default { this.$refs.productTable?.clearSelection() const requestOptions = allowNetworkRetry ? { suppressNetworkError: true } : {} try { - const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage }, requestOptions) + const [createdFrom = '', createdTo = ''] = this.query.createdAtRange || [] + const r = await listSybProducts({ page: this.query.page, pageSize: this.query.pageSize, shopName: this.query.shopName.trim(), orderCodes: orderCodes.join(','), parseStatus: this.query.parseStatus, processStage: this.query.processStage, createdFrom, createdTo }, requestOptions) if (generation !== this.loadGeneration) return this.products = r.data.items this.total = r.data.total @@ -432,7 +454,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: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] }; this.load() }, normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() }, async ensureShopOptions() { if (this.shopOptionsLoaded) return @@ -477,16 +499,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()