fix: 库存列表分类筛选失效,后端补上 category 参数
前端 stores/stock.js 一直在发 category,但 handler 只读 search,service 与 repository 的方法签名里也没有这个参数,整条链路把它静默丢弃,于是选完分类 点「查询」没有任何反应。前端本来就传对了,无需改动。 repository 侧按大类 code 过滤(material_categories 里 parent_id 为空的那层, 即 SQL 里的 pc.code),与列表「大类类型」列同口径,前端下拉的 value 正是大类 code。注意 GetAllStock 的 COUNT 查询原本没有 JOIN 分类表,加 pc.code 前必须 补上与主查询一致的 LEFT JOIN,否则分页总数会和实际行数对不上、末尾多出空白 页;两张表都按主键关联,实测计数不变。小类码不会误命中(category=A01 返回 0)。 顺带修掉同一功能里的耦合缺陷:updateWarningStock 刷新列表时不传筛选参数, 筛选一旦生效,保存预警就会把列表刷回全量、而下拉框仍显示选中的分类。store 新增 stockFilters 记住当前条件,刷新时沿用。 stock_test.go 里 COUNT 的正则随 SQL 变形同步放宽,新增 TestStockRepoGetAllStockAppliesCategoryToBothQueries 覆盖两条 SQL 的成对条件 与参数顺序;前端补 4 条用例(store 沿用/清空筛选、组件查询与翻页传递分类)。
This commit is contained in:
parent
121d0c9e1f
commit
71d1829c29
|
|
@ -14,7 +14,12 @@ export const useStockStore = defineStore('stock', () => {
|
|||
// 入库表单还要读预警阈值,共用同一份缓存避免重复请求。
|
||||
const stockCache = ref({})
|
||||
|
||||
// 列表当前生效的筛选条件。保存预警之类的操作会在原地重新拉取列表,那次拉取必须
|
||||
// 沿用它们,否则列表会悄悄退回全量,而下拉框里还显示着用户选中的筛选值。
|
||||
const stockFilters = ref({ search: '', category: '' })
|
||||
|
||||
async function fetchStock(page = 1, search = '', category = '') {
|
||||
stockFilters.value = { search, category }
|
||||
loading.value = true
|
||||
try {
|
||||
const pageSize = stockPage.value.pageSize
|
||||
|
|
@ -105,7 +110,7 @@ export const useStockStore = defineStore('stock', () => {
|
|||
// 阈值改了,单物料缓存里的旧值必须作废,否则入库表单会预填到过期数据。
|
||||
delete stockCache.value[materialId]
|
||||
await Promise.all([
|
||||
fetchStock(stockPage.value.currentPage),
|
||||
fetchStock(stockPage.value.currentPage, stockFilters.value.search, stockFilters.value.category),
|
||||
fetchStockWarning(warningPage.value.currentPage)
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,34 @@ describe('stock store warning settings', () => {
|
|||
expect(quantity).toBe('12.500000')
|
||||
})
|
||||
|
||||
// 保存预警会在原地重新拉取列表,那次拉取必须沿用用户当前的筛选条件:否则列表会
|
||||
// 悄悄退回全量,而下拉框里还显示着选中的分类,用户以为筛选还在生效。
|
||||
it('保存预警后刷新列表要沿用当前的筛选条件', async () => {
|
||||
const store = useStockStore()
|
||||
|
||||
await store.fetchStock(1, '轴承', 'A01')
|
||||
stockApi.getAll.mockClear()
|
||||
|
||||
await store.updateWarningStock(1, 8)
|
||||
|
||||
expect(stockApi.getAll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ search: '轴承', category: 'A01' })
|
||||
)
|
||||
})
|
||||
|
||||
// 首次加载没有筛选条件,别把上一次的筛选状态带到新页面上。
|
||||
it('不带筛选条件地重新查询会清掉旧的筛选状态', async () => {
|
||||
const store = useStockStore()
|
||||
|
||||
await store.fetchStock(1, '轴承', 'A01')
|
||||
stockApi.getAll.mockClear()
|
||||
await store.fetchStock(1)
|
||||
|
||||
expect(stockApi.getAll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ search: '', category: '' })
|
||||
)
|
||||
})
|
||||
|
||||
// 入库表单选物料时会为了预填预警值而查询库存,顺手把当时(入库前)的数量缓存下来。
|
||||
// 入库保存后库存已经变了,缓存若不失效,出库表单会一直显示入库前的旧数字,
|
||||
// 用户得刷新页面才能看到正确值。
|
||||
|
|
|
|||
|
|
@ -123,3 +123,45 @@ describe('库存预警阈值校验', () => {
|
|||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('库存分类筛选', () => {
|
||||
beforeEach(() => {
|
||||
stockStore.fetchStock.mockReset()
|
||||
stockStore.fetchStockLogs.mockReset()
|
||||
stockStore.fetchStockWarning.mockReset()
|
||||
materialsStore.fetchMaterialOptions.mockReset()
|
||||
materialsStore.fetchHistoricalMaterialOptions.mockReset()
|
||||
})
|
||||
|
||||
// 选中的大类要跟着查询一起发给 store,否则后端收不到 category、条件被静默丢弃,
|
||||
// 用户点「查询」看起来毫无反应。
|
||||
test('选中的大类随查询一起发给 store', async () => {
|
||||
const wrapper = mountStock()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.$.setupState.categoryFilter = 'A01'
|
||||
await flushPromises()
|
||||
|
||||
stockStore.fetchStock.mockClear()
|
||||
await findButton(wrapper, '查询').trigger('click')
|
||||
|
||||
expect(stockStore.fetchStock).toHaveBeenCalledWith(1, '', 'A01')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// 翻页同样要带着筛选条件,否则第 2 页会退回全量数据。
|
||||
test('翻页时也带着分类筛选', async () => {
|
||||
const wrapper = mountStock()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.$.setupState.categoryFilter = 'A01'
|
||||
await flushPromises()
|
||||
|
||||
stockStore.fetchStock.mockClear()
|
||||
wrapper.vm.$.setupState.handlePageChange(2)
|
||||
await flushPromises()
|
||||
|
||||
expect(stockStore.fetchStock).toHaveBeenCalledWith(2, '', 'A01')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -737,8 +737,10 @@ func (h *Handler) ListStock(c *gin.Context) {
|
|||
}
|
||||
|
||||
search := c.Query("search")
|
||||
// 大类编码筛选,前端下拉选中的就是 material_categories 里的大类 code
|
||||
category := c.Query("category")
|
||||
|
||||
stocks, total, err := h.stockSvc.GetAllStock(search, limit, offset)
|
||||
stocks, total, err := h.stockSvc.GetAllStock(search, category, limit, offset)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
|
|
|
|||
|
|
@ -44,8 +44,16 @@ func NewStockRepo() *StockRepo {
|
|||
return &StockRepo{}
|
||||
}
|
||||
|
||||
func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) {
|
||||
countQuery := `SELECT COUNT(*) FROM materials m WHERE 1=1` + stockedMaterialFilterSQL
|
||||
// GetAllStock 的 category 收的是大类编码(material_categories 里 parent_id 为空的那层)。
|
||||
// 按大类 code 而不是 category_id 过滤,是为了和前端下拉直接选出的大类选项对上。
|
||||
func (r *StockRepo) GetAllStock(search, category string, limit, offset int) ([]model.Stock, int64, error) {
|
||||
// COUNT 和主查询是两条独立 SQL,筛选条件必须成对出现,否则分页总数会和实际行数
|
||||
// 对不上、末尾多出空白页。主查询本来就 JOIN 了分类表,这里为按大类过滤补上同样的 JOIN;
|
||||
// 两张表都按主键关联,不会放大行数,COUNT 的语义不变。
|
||||
countQuery := `SELECT COUNT(*) FROM materials m
|
||||
LEFT JOIN material_categories c ON c.id = m.category_id
|
||||
LEFT JOIN material_categories pc ON pc.id = c.parent_id
|
||||
WHERE 1=1` + stockedMaterialFilterSQL
|
||||
var countArgs []interface{}
|
||||
|
||||
if search != "" {
|
||||
|
|
@ -53,6 +61,10 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock
|
|||
searchPattern := "%" + search + "%"
|
||||
countArgs = append(countArgs, searchPattern, searchPattern)
|
||||
}
|
||||
if category != "" {
|
||||
countQuery += ` AND pc.code = ?`
|
||||
countArgs = append(countArgs, category)
|
||||
}
|
||||
|
||||
var total int64
|
||||
err := database.DB.QueryRow(countQuery, countArgs...).Scan(&total)
|
||||
|
|
@ -78,6 +90,10 @@ func (r *StockRepo) GetAllStock(search string, limit, offset int) ([]model.Stock
|
|||
searchPattern := "%" + search + "%"
|
||||
args = append(args, searchPattern, searchPattern)
|
||||
}
|
||||
if category != "" {
|
||||
query += ` AND pc.code = ?`
|
||||
args = append(args, category)
|
||||
}
|
||||
|
||||
query += ` GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code ORDER BY m.id LIMIT ? OFFSET ?`
|
||||
args = append(args, limit, offset)
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ func TestStockRepoGetAllStockFiltersBothCountAndList(t *testing.T) {
|
|||
database.DB = db
|
||||
|
||||
filter := regexp.QuoteMeta(stockedMaterialFilterSQL)
|
||||
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\) FROM materials m WHERE 1=1.*` + filter).
|
||||
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\) FROM materials m.*` + filter).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*`+filter+`.*ORDER BY m\.id LIMIT \? OFFSET \?`).
|
||||
WithArgs(20, 0).
|
||||
|
|
@ -254,7 +254,7 @@ func TestStockRepoGetAllStockFiltersBothCountAndList(t *testing.T) {
|
|||
"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "warning_configured", "major_category", "minor_category",
|
||||
}).AddRow(7, "MAT-007", "轴承", "6204", "个", "4.000000", "10.000000", true, true, "A01", "A01-01"))
|
||||
|
||||
stocks, total, err := NewStockRepo().GetAllStock("", 20, 0)
|
||||
stocks, total, err := NewStockRepo().GetAllStock("", "", 20, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get all stock: %v", err)
|
||||
}
|
||||
|
|
@ -269,6 +269,45 @@ func TestStockRepoGetAllStockFiltersBothCountAndList(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// 大类筛选同样要成对落到 COUNT 和主查询上,参数顺序也得与占位符一致:只改一边的话,
|
||||
// 筛选后的列表会配上一个没筛选的总数,翻到最后一页只会看到空白。
|
||||
func TestStockRepoGetAllStockAppliesCategoryToBothQueries(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("open sql mock: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
database.DB = nil
|
||||
db.Close()
|
||||
})
|
||||
database.DB = db
|
||||
|
||||
filter := regexp.QuoteMeta(stockedMaterialFilterSQL)
|
||||
// 主查询本来就 JOIN 了分类表,COUNT 那条也必须补上,否则拿 pc.code 过滤会直接报未知列
|
||||
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\) FROM materials m.*LEFT JOIN material_categories pc.*` + filter + `.*AND pc\.code = \?`).
|
||||
WithArgs("A01").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*` + filter + `.*AND pc\.code = \?.*ORDER BY m\.id LIMIT \? OFFSET \?`).
|
||||
WithArgs("A01", 20, 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "warning_configured", "major_category", "minor_category",
|
||||
}).AddRow(7, "MAT-007", "轴承", "6204", "个", "4.000000", "10.000000", true, true, "A01", "A01-01"))
|
||||
|
||||
stocks, total, err := NewStockRepo().GetAllStock("", "A01", 20, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get all stock by category: %v", err)
|
||||
}
|
||||
if total != 1 || len(stocks) != 1 {
|
||||
t.Fatalf("unexpected result: total=%d stocks=%+v", total, stocks)
|
||||
}
|
||||
if stocks[0].MajorCategory != "A01" {
|
||||
t.Fatalf("unexpected major category: %+v", stocks[0])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 预警列表同样有 COUNT 和主查询两条 SQL,而且两条都必须带上与 service.ClassifyStock
|
||||
// 对应的 HAVING 判定。
|
||||
func TestStockRepoGetWarningStockAppliesThresholdRuleToBothQueries(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ func classifyAll(stocks []model.Stock) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *StockService) GetAllStock(search string, limit, offset int) ([]model.Stock, int64, error) {
|
||||
stocks, total, err := s.stockRepo.GetAllStock(search, limit, offset)
|
||||
func (s *StockService) GetAllStock(search, category string, limit, offset int) ([]model.Stock, int64, error) {
|
||||
stocks, total, err := s.stockRepo.GetAllStock(search, category, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user