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 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", "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 和主查询两条 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) } }