material_manage_system/internal/repository/stock_test.go
文睿 张 71d1829c29 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 沿用/清空筛选、组件查询与翻页传递分类)。
2026-09-21 16:19:18 +08:00

344 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repository
import (
"regexp"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/pkg/database"
)
const stockByMaterialSQL = `SELECT m.id, m.material_code, m.name, m.spec, m.unit,
COALESCE(SUM(ib.on_hand), 0), COALESCE(MAX(swr.minimum_available), 0), COALESCE(MAX(swr.enabled), FALSE),
MAX(swr.material_id) IS NOT NULL,
IFNULL(pc.code, ''), IFNULL(c.code, '')
FROM materials m
LEFT JOIN inventory_balances ib ON ib.material_id = m.id
LEFT JOIN stock_warning_rules swr ON swr.organization_id = ib.organization_id
AND swr.warehouse_id = ib.warehouse_id AND swr.material_id = m.id
LEFT JOIN material_categories c ON c.id = m.category_id
LEFT JOIN material_categories pc ON pc.id = c.parent_id
WHERE m.id = ?
GROUP BY m.id, m.material_code, m.name, m.spec, m.unit, pc.code, c.code`
func TestStockRepoGetByMaterialIDReadsInventoryTables(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
mock.ExpectQuery(regexp.QuoteMeta(stockByMaterialSQL)).WithArgs(int64(9)).WillReturnRows(
sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "warning_configured", "major_category", "minor_category"}).
AddRow(9, "MAT-009", "电机", "80W", "台", "12.500000", "3.000000", true, true, "B01", "CAT-SUB-027"),
)
stock, err := NewStockRepo().GetByMaterialID(9)
if err != nil {
t.Fatalf("get stock: %v", err)
}
if !stock.TotalOnHand.Equal(decimal.RequireFromString("12.500000")) || !stock.WarningStock.Equal(decimal.NewFromInt(3)) {
t.Fatalf("unexpected stock quantities: %+v", stock)
}
if !stock.WarningEnabled {
t.Fatalf("expected warning to read back as enabled: %+v", stock)
}
if stock.MajorCategory != "B01" || stock.MinorCategory != "CAT-SUB-027" {
t.Fatalf("unexpected stock categories: %+v", stock)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestStockRepoGetStockPreservesDecimalQuantity(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
query := `SELECT COALESCE(SUM(on_hand), 0) FROM inventory_balances WHERE material_id = ?`
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(9)).WillReturnRows(
sqlmock.NewRows([]string{"on_hand"}).AddRow("1500.250000"),
)
stock, err := NewStockRepo().GetStock(9)
if err != nil {
t.Fatalf("get stock: %v", err)
}
if !stock.Equal(decimal.RequireFromString("1500.250000")) {
t.Fatalf("stock = %s, want 1500.250000", stock)
}
}
func TestStockRepoUpdateStockTxWritesDefaultInventoryBalance(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
mock.ExpectBegin()
tx, err := db.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
query := `INSERT INTO inventory_balances (organization_id, warehouse_id, location_id, material_id, on_hand)
SELECT o.id, w.id, l.id, ?, ?
FROM organizations o
JOIN warehouses w ON w.organization_id = o.id AND w.code = 'DEFAULT'
JOIN warehouse_locations l ON l.warehouse_id = w.id AND l.code = 'MAIN'
WHERE o.code = 'DEFAULT'
ON DUPLICATE KEY UPDATE on_hand = on_hand + VALUES(on_hand), version = version + 1`
mock.ExpectExec(regexp.QuoteMeta(query)).WithArgs(int64(11), "5.5").WillReturnResult(sqlmock.NewResult(1, 1))
if err := NewStockRepo().UpdateStockTx(tx, 11, decimal.RequireFromString("5.500000")); err != nil {
t.Fatalf("update inventory balance: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestStockRepoUpdateWarningStockWritesInventoryRule(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
mock.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
WithArgs(int64(13), "8.75", true).WillReturnResult(sqlmock.NewResult(1, 1))
if err := NewStockRepo().UpdateWarningStock(13, decimal.RequireFromString("8.750000"), true); err != nil {
t.Fatalf("update stock warning: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 阈值为 0 是合法配置,含义是"库存降到 0 时预警",必须原样落库而不是被当成"未设置"。
func TestStockRepoUpdateWarningStockAcceptsZeroThreshold(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
mock.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
WithArgs(int64(13), "0", true).WillReturnResult(sqlmock.NewResult(1, 1))
if err := NewStockRepo().UpdateWarningStock(13, decimal.Zero, true); err != nil {
t.Fatalf("update stock warning: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 关闭预警时只翻 enabled,阈值本身要原样保留,方便之后重新启用。
func TestStockRepoUpdateWarningStockCanDisableKeepingThreshold(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
mock.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
WithArgs(int64(13), "8.75", false).WillReturnResult(sqlmock.NewResult(1, 1))
if err := NewStockRepo().UpdateWarningStock(13, decimal.RequireFromString("8.750000"), false); err != nil {
t.Fatalf("update stock warning: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 重复保存同一个阈值时 MySQL 返回 0 影响行,这跟"找不到默认组织/仓库"是两种不同情况,
// 不能把它当成错误报出来。
func TestStockRepoUpdateWarningStockKeepsUnchangedThreshold(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
mock.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
WithArgs(int64(13), "8.75", true).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(regexp.QuoteMeta(defaultWarehouseExistsSQL)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
if err := NewStockRepo().UpdateWarningStock(13, decimal.RequireFromString("8.750000"), true); err != nil {
t.Fatalf("unchanged threshold must not fail: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestStockRepoUpdateWarningStockReportsMissingDefaultWarehouse(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
mock.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
WithArgs(int64(13), "8.75", true).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(regexp.QuoteMeta(defaultWarehouseExistsSQL)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
if err := NewStockRepo().UpdateWarningStock(13, decimal.RequireFromString("8.750000"), true); err == nil {
t.Fatal("expected an error when the default organization/warehouse is missing")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 库存列表和它的 COUNT 查询是两条独立 SQL,筛选条件必须同时出现在两边,否则分页总数
// 会和实际行数对不上、末尾多出空白页。
func TestStockRepoGetAllStockFiltersBothCountAndList(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)
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).
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("", "", 20, 0)
if err != nil {
t.Fatalf("get all stock: %v", err)
}
if total != 1 || len(stocks) != 1 {
t.Fatalf("unexpected result: total=%d stocks=%+v", total, stocks)
}
if !stocks[0].WarningStock.Equal(decimal.NewFromInt(10)) || !stocks[0].WarningEnabled {
t.Fatalf("unexpected warning fields: %+v", stocks[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 大类筛选同样要成对落到 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) {
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
having := regexp.QuoteMeta(stockWarningHavingSQL)
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\) FROM \(.*` + having).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*`+having+`.*ORDER BY m\.id LIMIT \? OFFSET \?`).
WithArgs(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", "个", "0.000000", "10.000000", true, true, "A01", "A01-01"))
stocks, total, err := NewStockRepo().GetWarningStock(20, 0)
if err != nil {
t.Fatalf("get warning stock: %v", err)
}
if total != 1 || len(stocks) != 1 {
t.Fatalf("unexpected result: total=%d stocks=%+v", total, stocks)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}