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:
文睿 张 2026-09-21 16:19:18 +08:00
parent 121d0c9e1f
commit 71d1829c29
7 changed files with 140 additions and 8 deletions

View File

@ -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)
])
}

View File

@ -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: '' })
)
})
// 入库表单选物料时会为了预填预警值而查询库存,顺手把当时(入库前)的数量缓存下来。
// 入库保存后库存已经变了,缓存若不失效,出库表单会一直显示入库前的旧数字,
// 用户得刷新页面才能看到正确值。

View File

@ -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()
})
})

View File

@ -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

View File

@ -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)

View File

@ -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) {

View File

@ -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
}