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) } }