前端 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 沿用/清空筛选、组件查询与翻页传递分类)。
344 lines
12 KiB
Go
344 lines
12 KiB
Go
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)
|
||
}
|
||
}
|