572 lines
20 KiB
Go
572 lines
20 KiB
Go
package repository
|
||
|
||
import (
|
||
"database/sql"
|
||
"regexp"
|
||
"sync"
|
||
"testing"
|
||
|
||
"github.com/DATA-DOG/go-sqlmock"
|
||
"github.com/shopspring/decimal"
|
||
"github.com/wmms/wmms/internal/model"
|
||
"github.com/wmms/wmms/pkg/database"
|
||
)
|
||
|
||
func stockTestScope() model.InventoryScope {
|
||
return model.InventoryScope{OrganizationID: 1, WarehouseID: 2, LocationID: 3}
|
||
}
|
||
|
||
func TestStockRepoGetByMaterialIDScopesToOrganization(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
|
||
|
||
// 组织条件是硬约束:物料不属于当前公司时查不到,公司 A 不能凭物料 ID 读到 B 的库存。
|
||
mock.ExpectQuery(`(?s)FROM materials m.*WHERE m\.organization_id = \? AND m\.id = \?`).
|
||
WithArgs(int64(1), 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(1, 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)
|
||
}
|
||
}
|
||
|
||
// 跨公司读取:带 organization_id 的查询查不到别家公司的物料,返回 sql.ErrNoRows,Handler 据此回 404。
|
||
func TestStockRepoGetByMaterialIDMissingInOrganizationReturnsNoRows(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(`(?s)FROM materials m.*WHERE m\.organization_id = \? AND m\.id = \?`).
|
||
WithArgs(int64(1), int64(9)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "warning_configured", "major_category", "minor_category"}))
|
||
|
||
_, err = NewStockRepo().GetByMaterialID(1, 9)
|
||
if err != sql.ErrNoRows {
|
||
t.Fatalf("cross-organization read must return sql.ErrNoRows, got %v", err)
|
||
}
|
||
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
|
||
|
||
// 精确余额按 scope 的三个维度取,不再全局汇总。
|
||
mock.ExpectQuery(`(?s)SELECT COALESCE\(SUM\(on_hand\), 0\) FROM inventory_balances\s+WHERE organization_id = \? AND warehouse_id = \? AND location_id = \? AND material_id = \?`).
|
||
WithArgs(int64(1), int64(2), int64(3), int64(9)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("1500.250000"))
|
||
|
||
stock, err := NewStockRepo().GetStock(stockTestScope(), 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 TestStockRepoGetStockForUpdateTxTreatsMissingRowAsZero(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)
|
||
}
|
||
mock.ExpectQuery(`(?s)SELECT on_hand FROM inventory_balances\s+WHERE organization_id = \? AND warehouse_id = \? AND location_id = \? AND material_id = \?\s+FOR UPDATE`).
|
||
WithArgs(int64(1), int64(2), int64(3), int64(11)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"on_hand"}))
|
||
|
||
stock, err := NewStockRepo().GetStockForUpdateTx(tx, stockTestScope(), 11)
|
||
if err != nil {
|
||
t.Fatalf("locked stock: %v", err)
|
||
}
|
||
if !stock.IsZero() {
|
||
t.Fatalf("missing balance row must be 0, got %s", stock)
|
||
}
|
||
}
|
||
|
||
func TestStockRepoUpdateStockTxWritesScopedInventoryBalance(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)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE on_hand = on_hand + VALUES(on_hand), version = version + 1`
|
||
// 维度显式落库:不再有 DEFAULT 公司/仓库、MAIN 库位的兜底子查询。
|
||
mock.ExpectExec(regexp.QuoteMeta(query)).WithArgs(int64(1), int64(2), int64(3), int64(11), "5.5").
|
||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||
|
||
if err := NewStockRepo().UpdateStockTx(tx, stockTestScope(), 11, decimal.RequireFromString("5.500000")); err != nil {
|
||
t.Fatalf("update inventory balance: %v", err)
|
||
}
|
||
if err := mock.ExpectationsWereMet(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// 条件扣减:余额不足时影响 0 行并返回 ErrInsufficientStock,绝不透支。
|
||
func TestStockRepoDeductStockTxRejectsWhenRowAbsentOrInsufficient(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)
|
||
}
|
||
mock.ExpectExec(`(?s)UPDATE inventory_balances SET on_hand = on_hand - \?.*AND on_hand >= \?`).
|
||
WithArgs("7", int64(1), int64(2), int64(3), int64(11), "7").
|
||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||
|
||
err = NewStockRepo().DeductStockTx(tx, stockTestScope(), 11, decimal.NewFromInt(7))
|
||
if err != ErrInsufficientStock {
|
||
t.Fatalf("want ErrInsufficientStock, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestStockRepoUpdateWarningStockWritesScopedInventoryRule(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
|
||
|
||
// 预警规则写显式的公司、仓库维度,不再从 DEFAULT/MAIN 兜底。
|
||
mock.ExpectExec(regexp.QuoteMeta(upsertStockWarningRuleSQL)).
|
||
WithArgs(int64(1), int64(2), int64(13), "8.75", true).WillReturnResult(sqlmock.NewResult(1, 1))
|
||
|
||
if err := NewStockRepo().UpdateWarningStock(stockTestScope(), 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(1), int64(2), int64(13), "0", true).WillReturnResult(sqlmock.NewResult(1, 1))
|
||
|
||
if err := NewStockRepo().UpdateWarningStock(stockTestScope(), 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(1), int64(2), int64(13), "8.75", false).WillReturnResult(sqlmock.NewResult(1, 1))
|
||
|
||
if err := NewStockRepo().UpdateWarningStock(stockTestScope(), 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 影响行,这表示"值没变"而不是失败,不能报错。
|
||
// 配置缺失已经由 ResolveInventoryScope 在调用前拦下,这里不再需要额外的存在性查询。
|
||
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(1), int64(2), int64(13), "8.75", true).WillReturnResult(sqlmock.NewResult(0, 0))
|
||
|
||
if err := NewStockRepo().UpdateWarningStock(stockTestScope(), 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)
|
||
}
|
||
}
|
||
|
||
// 库存列表和它的 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 m\.organization_id = \?` + filter).
|
||
WithArgs(int64(1)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*`+filter+`.*ORDER BY m\.id LIMIT \? OFFSET \?`).
|
||
WithArgs(int64(1), 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(1, "", "", 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(int64(1), "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(int64(1), "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(1, "", "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).
|
||
WithArgs(int64(1)).
|
||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||
mock.ExpectQuery(`(?s)SELECT m\.id, m\.material_code.*`+having+`.*ORDER BY m\.id LIMIT \? OFFSET \?`).
|
||
WithArgs(int64(1), 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(1, 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)
|
||
}
|
||
}
|
||
|
||
// ---- 真实 MySQL 双公司验收(未配置 TEST_DB_DSN 时 skip,留给 Task 8 统一执行)----
|
||
|
||
// seedStockIsolation 播种两个公司(ID 101/102,避开 init.sql 预置的默认公司 1),
|
||
// 同物料编码但不同 ID/余额,各配一组仓库、库位。公司 A=101:物料 101、仓库 101、
|
||
// 库位 101、余额 100、预警阈值 60(启用);公司 B=102:物料 102、仓库 102、库位 102、
|
||
// 余额 5、无预警规则。两家各自一条库存流水。
|
||
func seedStockIsolation(t *testing.T, db *sql.DB) {
|
||
t.Helper()
|
||
mustSQL(t, db, `INSERT INTO organizations(id,code,name) VALUES (101,'ONE','One'),(102,'TWO','Two');
|
||
INSERT INTO material_categories(id,organization_id,code,name) VALUES (101,101,'C','C'),(102,102,'C','C');
|
||
INSERT INTO units(id,organization_id,code,name) VALUES (101,101,'U','U'),(102,102,'U','U');
|
||
INSERT INTO materials(id,organization_id,material_code,name,spec,unit,category_id,base_unit_id) VALUES (101,101,'M','M','','pcs',101,101),(102,102,'M','M','','pcs',102,102);
|
||
INSERT INTO warehouses(id,organization_id,code,name,status) VALUES (101,101,'W','W','active'),(102,102,'W','W','active');
|
||
INSERT INTO warehouse_locations(id,warehouse_id,code,name,status) VALUES (101,101,'L','L','active'),(102,102,'L','L','active');
|
||
INSERT INTO inventory_balances(organization_id,warehouse_id,location_id,material_id,on_hand) VALUES (101,101,101,101,100),(102,102,102,102,5);
|
||
INSERT INTO stock_warning_rules(organization_id,warehouse_id,material_id,minimum_available,enabled) VALUES (101,101,101,60,1);
|
||
INSERT INTO stock_logs(organization_id,warehouse_id,location_id,material_id,type,quantity,related_order_type,related_order_id) VALUES (101,101,101,101,'in',100,'inbound',1),(102,102,102,102,'in',5,'inbound',2);`)
|
||
}
|
||
|
||
func TestStockRepoOrganizationIsolationReads(t *testing.T) {
|
||
db := isolationDB(t, false)
|
||
seedStockIsolation(t, db)
|
||
repo := NewStockRepo()
|
||
|
||
// A 的库存列表、总数只含 A;B 的物料不出现在 A 的列表里。
|
||
stocksA, totalA, err := repo.GetAllStock(101, "", "", 20, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if totalA != 1 || len(stocksA) != 1 || stocksA[0].MaterialID != 101 {
|
||
t.Fatalf("company A list=%+v total=%d, want only material 101", stocksA, totalA)
|
||
}
|
||
if !stocksA[0].TotalOnHand.Equal(decimal.NewFromInt(100)) {
|
||
t.Fatalf("company A balance=%s, want 100", stocksA[0].TotalOnHand)
|
||
}
|
||
stocksB, totalB, err := repo.GetAllStock(102, "", "", 20, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if totalB != 1 || len(stocksB) != 1 || stocksB[0].MaterialID != 102 {
|
||
t.Fatalf("company B list=%+v total=%d, want only material 102", stocksB, totalB)
|
||
}
|
||
|
||
// 详情只返回本公司物料;传 B 的物料 ID 在 A 上下文查不到。
|
||
detailA, err := repo.GetByMaterialID(101, 101)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if detailA.MaterialID != 101 {
|
||
t.Fatalf("company A detail material=%d", detailA.MaterialID)
|
||
}
|
||
if _, err := repo.GetByMaterialID(101, 102); err != sql.ErrNoRows {
|
||
t.Fatalf("company A reading material 102 must return ErrNoRows, got %v", err)
|
||
}
|
||
|
||
// 预警:A 的库存 100 高于阈值 60,不预警;把 A 阈值调到 100 才进入预警,且不影响 B。
|
||
warnA, _, err := repo.GetWarningStock(101, 20, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(warnA) != 0 {
|
||
t.Fatalf("company A should have no warning before adjustment, got %+v", warnA)
|
||
}
|
||
if err := repo.UpdateWarningStock(model.InventoryScope{OrganizationID: 101, WarehouseID: 101, LocationID: 101}, 101, decimal.NewFromInt(100), true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
warnA, _, err = repo.GetWarningStock(101, 20, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(warnA) != 1 || warnA[0].MaterialID != 101 {
|
||
t.Fatalf("company A warning after adjustment=%+v", warnA)
|
||
}
|
||
// B 没有预警规则,从未预警。
|
||
warnB, _, err := repo.GetWarningStock(102, 20, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(warnB) != 0 {
|
||
t.Fatalf("company B warning=%+v, want none", warnB)
|
||
}
|
||
|
||
// 流水只含本公司:A 只看到自己的一条。
|
||
logsA, totalLogsA, err := repo.GetAllLogsWithBalance(101, 20, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if totalLogsA != 1 || len(logsA) != 1 || logsA[0].MaterialID != 101 {
|
||
t.Fatalf("company A logs=%+v total=%d", logsA, totalLogsA)
|
||
}
|
||
byMaterialA, totalByMaterialA, err := repo.GetLogsByMaterialID(101, 101, 20, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if totalByMaterialA != 1 || len(byMaterialA) != 1 {
|
||
t.Fatalf("company A logs by material=%+v total=%d", byMaterialA, totalByMaterialA)
|
||
}
|
||
// A 上下文读 B 的物料流水:一条都没有。
|
||
byMaterialCross, totalCross, err := repo.GetLogsByMaterialID(101, 102, 20, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if totalCross != 0 || len(byMaterialCross) != 0 {
|
||
t.Fatalf("company A reading material 102 logs must be empty, got %+v", byMaterialCross)
|
||
}
|
||
}
|
||
|
||
// 并发扣减:两家公司各有独立余额,同公司的并发扣减只能成功一次,绝不透支,
|
||
// 也不会创建或改动别家公司的余额。
|
||
func TestStockRepoConcurrentDeductionDoesNotOverdraft(t *testing.T) {
|
||
db := isolationDB(t, false)
|
||
seedStockIsolation(t, db)
|
||
repo := NewStockRepo()
|
||
scopeA := model.InventoryScope{OrganizationID: 101, WarehouseID: 101, LocationID: 101}
|
||
|
||
const workers = 4
|
||
var wg sync.WaitGroup
|
||
start := make(chan struct{})
|
||
results := make(chan error, workers)
|
||
deduct := decimal.NewFromInt(40) // 余额 100,4 个人各扣 40,最多成功 2 次
|
||
for i := 0; i < workers; i++ {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
tx, err := db.Begin()
|
||
if err != nil {
|
||
results <- err
|
||
return
|
||
}
|
||
<-start
|
||
if err := repo.DeductStockTx(tx, scopeA, 101, deduct); err != nil {
|
||
tx.Rollback()
|
||
results <- err
|
||
return
|
||
}
|
||
results <- tx.Commit()
|
||
}()
|
||
}
|
||
close(start)
|
||
wg.Wait()
|
||
close(results)
|
||
|
||
successes := 0
|
||
for err := range results {
|
||
if err == nil {
|
||
successes++
|
||
} else if err != ErrInsufficientStock {
|
||
t.Fatalf("unexpected deduction error: %v", err)
|
||
}
|
||
}
|
||
if successes != 2 {
|
||
t.Fatalf("concurrent deductions succeeded=%d, want 2 (100/40)", successes)
|
||
}
|
||
var onHand string
|
||
if err := db.QueryRow(`SELECT on_hand FROM inventory_balances WHERE organization_id=101 AND material_id=101`).Scan(&onHand); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if onHand != "20.000000" {
|
||
t.Fatalf("company A balance after deduction=%s, want 20.000000 (never negative)", onHand)
|
||
}
|
||
// B 的余额不受影响,也没有给 A 创建默认公司的余额。
|
||
var countA int
|
||
if err := db.QueryRow(`SELECT COUNT(*) FROM inventory_balances WHERE organization_id=101`).Scan(&countA); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if countA != 1 {
|
||
t.Fatalf("company A balance rows=%d, want 1 (no default-company balance created)", countA)
|
||
}
|
||
var balanceB string
|
||
if err := db.QueryRow(`SELECT on_hand FROM inventory_balances WHERE organization_id=102 AND material_id=102`).Scan(&balanceB); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if balanceB != "5.000000" {
|
||
t.Fatalf("company B balance changed to %s, want untouched 5.000000", balanceB)
|
||
}
|
||
}
|