material_manage_system/internal/repository/stock_test.go
2026-10-09 11:10:13 +08:00

572 lines
20 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 (
"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)
}
}