material_manage_system/internal/repository/stock_test.go

258 lines
8.6 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), TRUE),
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", "major_category", "minor_category"}).
AddRow(9, "MAT-009", "电机", "80W", "台", "12.500000", "3.000000", 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)
}
}
// 重复保存同一个阈值时 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 WHERE 1=1.*` + 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", "major_category", "minor_category",
}).AddRow(7, "MAT-007", "轴承", "6204", "个", "4.000000", "10.000000", 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 和主查询两条 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", "major_category", "minor_category",
}).AddRow(7, "MAT-007", "轴承", "6204", "个", "0.000000", "10.000000", 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)
}
}