diff --git a/frontend/src/stores/stock.js b/frontend/src/stores/stock.js index 8e8ad94d..ea9b6a8a 100644 --- a/frontend/src/stores/stock.js +++ b/frontend/src/stores/stock.js @@ -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) ]) } diff --git a/frontend/src/stores/stock.test.js b/frontend/src/stores/stock.test.js index 757265a7..a157af73 100644 --- a/frontend/src/stores/stock.test.js +++ b/frontend/src/stores/stock.test.js @@ -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: '' }) + ) + }) + // 入库表单选物料时会为了预填预警值而查询库存,顺手把当时(入库前)的数量缓存下来。 // 入库保存后库存已经变了,缓存若不失效,出库表单会一直显示入库前的旧数字, // 用户得刷新页面才能看到正确值。 diff --git a/frontend/src/views/Stock.test.js b/frontend/src/views/Stock.test.js index 8bb06379..02bcb92f 100644 --- a/frontend/src/views/Stock.test.js +++ b/frontend/src/views/Stock.test.js @@ -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() + }) +}) diff --git a/internal/handler/api.go b/internal/handler/api.go index d7b9a94c..f7f8c8b4 100644 --- a/internal/handler/api.go +++ b/internal/handler/api.go @@ -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 diff --git a/internal/repository/stock.go b/internal/repository/stock.go index c5523955..ec0b8f95 100644 --- a/internal/repository/stock.go +++ b/internal/repository/stock.go @@ -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) diff --git a/internal/repository/stock_test.go b/internal/repository/stock_test.go index f8ea63cf..3274f631 100644 --- a/internal/repository/stock_test.go +++ b/internal/repository/stock_test.go @@ -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) { diff --git a/internal/service/stock.go b/internal/service/stock.go index 3c0ff1c3..9653b170 100644 --- a/internal/service/stock.go +++ b/internal/service/stock.go @@ -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 }