feat(syb): filter products by created time (#342)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -69,6 +72,16 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
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))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
@@ -123,6 +124,46 @@ func TestServiceListCapsPageSizeAt500(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="创建时间"><el-date-picker v-model="query.createdAtRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable /></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
@@ -359,7 +360,7 @@ export default {
|
||||
imageSearchBatch: this.emptyImageSearchBatch(),
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
|
||||
query: { page: 1, pageSize: 200, shopName: '', orderCodesText: '', parseStatus: '', processStage: '', createdAtRange: [] },
|
||||
returnMatchByProductId: {},
|
||||
returnMatchLoading: false,
|
||||
returnMatchBatchLoading: false,
|
||||
@@ -432,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
|
||||
@@ -452,7 +454,7 @@ export default {
|
||||
}
|
||||
},
|
||||
search() { this.query.page = 1; this.load() },
|
||||
reset() { this.query = { page: 1, pageSize: 100, 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
|
||||
|
||||
Reference in New Issue
Block a user