package handler import ( "database/sql" "encoding/json" "fmt" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/go-sql-driver/mysql" "github.com/wmms/wmms/pkg/database" ) // TestOrderFlowIsolatesCompanyInventoryMySQL 用一张一次性 MySQL 库做端到端验收: // A 公司的入库/出库/编辑/删除只改变 A 公司的库存与单据,B 公司完全不受影响; // 库存不足、仓库停用等失败不会留下半张单据或孤立流水。真实库用例,未配 TEST_DB_DSN 时 skip。 func TestOrderFlowIsolatesCompanyInventoryMySQL(t *testing.T) { dsn := os.Getenv("TEST_DB_DSN") if dsn == "" { t.Skip("real MySQL integration requires TEST_DB_DSN") } cfg, err := mysql.ParseDSN(dsn) if err != nil { t.Fatal(err) } if !strings.HasPrefix(cfg.DBName, "company_isolation_test") { t.Fatal("TEST_DB_DSN must name a company_isolation_test schema") } cfg.DBName = "" cfg.MultiStatements = true cfg.ParseTime = true admin, err := sql.Open("mysql", cfg.FormatDSN()) if err != nil { t.Fatal(err) } name := fmt.Sprintf("company_isolation_test_order_%d", time.Now().UnixNano()) if _, err = admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { admin.Close() t.Fatal(err) } cfg.DBName = name db, err := sql.Open("mysql", cfg.FormatDSN()) if err != nil { admin.Exec("DROP DATABASE `" + name + "`") admin.Close() t.Fatal(err) } previous := database.DB database.DB = db t.Cleanup(func() { database.DB = previous; db.Close(); admin.Exec("DROP DATABASE `" + name + "`"); admin.Close() }) content, err := os.ReadFile(filepath.Join("..", "..", "scripts", "init.sql")) if err != nil { t.Fatal(err) } var lines []string for _, line := range strings.Split(strings.TrimPrefix(string(content), "\ufeff"), "\n") { if strings.HasPrefix(line, "CREATE DATABASE") || strings.HasPrefix(line, "USE ") { continue } lines = append(lines, line) } exec := func(query string, args ...interface{}) { t.Helper() if _, err := db.Exec(query, args...); err != nil { t.Fatalf("fixture SQL: %v\n%s", err, query) } } exec(strings.Join(lines, "\n")) // 公司 A=201、B=202 各配一组启用仓库、库位与一个物料,余额 A=10、B=50; // 用户 7 有 warehouse_admin 角色,并同时被授权公司 A、B。 exec(`INSERT INTO organizations(id,code,name,status) VALUES (201,'OI_A','公司A','active'),(202,'OI_B','公司B','active'); INSERT INTO users(id,username,password) VALUES (7,'order-user','x'); INSERT INTO user_roles(user_id,role_id) SELECT 7,id FROM roles WHERE code='warehouse_admin'; INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES (7,201,1),(7,202,0); INSERT INTO material_categories(id,organization_id,code,name) VALUES (201,201,'C','C'),(202,202,'C','C'); INSERT INTO units(id,organization_id,code,name) VALUES (201,201,'U','U'),(202,202,'U','U'); INSERT INTO materials(id,organization_id,material_code,name,spec,unit,category_id,base_unit_id) VALUES (201,201,'MA','A','','pcs',201,201),(202,202,'MB','B','','pcs',202,202); INSERT INTO warehouses(id,organization_id,code,name,status) VALUES (201,201,'W','W','active'),(202,202,'W','W','active'); INSERT INTO warehouse_locations(id,warehouse_id,code,name,status) VALUES (201,201,'L','L','active'),(202,202,'L','L','active'); INSERT INTO inventory_balances(organization_id,warehouse_id,location_id,material_id,on_hand) VALUES (201,201,201,201,10),(202,202,202,202,50);`) token := contextToken(t) gin.SetMode(gin.TestMode) router := gin.New() NewHandler().RegisterRoutes(router) request := func(method, path, body string, org int64) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("X-Organization-ID", fmt.Sprint(org)) if body != "" { req.Header.Set("Content-Type", "application/json") } w := httptest.NewRecorder() router.ServeHTTP(w, req) return w } expectStatus := func(w *httptest.ResponseRecorder, status int) { t.Helper() if w.Code != status { t.Fatalf("status=%d body=%s, want %d", w.Code, w.Body.String(), status) } } balance := func(org, material int64) string { t.Helper() var onHand string if err := db.QueryRow(`SELECT COALESCE(on_hand,0) FROM inventory_balances WHERE organization_id=? AND material_id=?`, org, material).Scan(&onHand); err != nil { t.Fatal(err) } return onHand } orderCount := func(table string, org int64) int { t.Helper() var count int if err := db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE organization_id=?`, org).Scan(&count); err != nil { t.Fatal(err) } return count } orphanLogs := func(org int64) int { t.Helper() var count int if err := db.QueryRow(`SELECT COUNT(*) FROM stock_logs sl WHERE sl.organization_id=? AND ((sl.related_order_type='inbound' AND sl.related_order_id NOT IN (SELECT id FROM inbound_orders WHERE organization_id=?)) OR (sl.related_order_type='outbound' AND sl.related_order_id NOT IN (SELECT id FROM outbound_orders WHERE organization_id=?)))`, org, org, org).Scan(&count); err != nil { t.Fatal(err) } return count } aBalanceSteps := []struct{ label, want string }{ {"初始", "10.000000"}, {"A 入库 5 后", "15.000000"}, {"A 出库 3 后", "12.000000"}, {"A 编辑入库 5→8 后", "15.000000"}, {"A 删除入库单后", "7.000000"}, } if got := balance(201, 201); got != aBalanceSteps[0].want { t.Fatalf("初始 A 余额=%s, want %s", got, aBalanceSteps[0].want) } if got := balance(202, 202); got != "50.000000" { t.Fatalf("初始 B 余额=%s, want 50.000000", got) } // 1) A 入库 5。 w := request("POST", "/api/inbound", `{"handler":"h","supplier":"s","items":[{"material_id":201,"quantity":5}]}`, 201) expectStatus(w, 201) var inbound struct { Data struct { ID int64 `json:"id"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &inbound); err != nil || inbound.Data.ID == 0 { t.Fatalf("解析入库单失败: %v body=%s", err, w.Body.String()) } if got := balance(201, 201); got != aBalanceSteps[1].want { t.Fatalf("A 入库后余额=%s, want %s", got, aBalanceSteps[1].want) } // 2) A 出库 3。 expectStatus(request("POST", "/api/outbound", `{"receiver":"r","items":[{"material_id":201,"quantity":3}]}`, 201), 201) if got := balance(201, 201); got != aBalanceSteps[2].want { t.Fatalf("A 出库后余额=%s, want %s", got, aBalanceSteps[2].want) } // 3) A 编辑入库单 5→8。 expectStatus(request("PUT", fmt.Sprintf("/api/inbound/%d", inbound.Data.ID), `{"handler":"h","supplier":"s","items":[{"material_id":201,"quantity":8}]}`, 201), 200) if got := balance(201, 201); got != aBalanceSteps[3].want { t.Fatalf("A 编辑后余额=%s, want %s", got, aBalanceSteps[3].want) } // 4) A 删除入库单。 expectStatus(request("DELETE", fmt.Sprintf("/api/inbound/%d", inbound.Data.ID), "", 201), 200) if got := balance(201, 201); got != aBalanceSteps[4].want { t.Fatalf("A 删除后余额=%s, want %s", got, aBalanceSteps[4].want) } // 至此 A 的库存与单据变化只落在 A 上,B 一直是 50、无单据无流水。 if got := balance(202, 202); got != "50.000000" { t.Fatalf("A 操作后 B 余额=%s, want 50.000000", got) } if orderCount("inbound_orders", 202) != 0 || orderCount("outbound_orders", 202) != 0 { t.Fatalf("A 操作影响了 B 的单据") } if got := orderCount("outbound_orders", 201); got != 1 { t.Fatalf("A 出库单数=%d, want 1", got) } // 5) 库存不足:A 出库 100 失败,余额不变,不留半张出库单或孤立流水。 logsBefore := countLogs(t, db, 201) expectStatus(request("POST", "/api/outbound", `{"receiver":"r","items":[{"material_id":201,"quantity":100}]}`, 201), 400) if got := balance(201, 201); got != aBalanceSteps[4].want { t.Fatalf("库存不足后 A 余额=%s, want %s", got, aBalanceSteps[4].want) } if got := orderCount("outbound_orders", 201); got != 1 { t.Fatalf("库存不足留下了半张出库单: %d 张", got) } if countLogs(t, db, 201) != logsBefore || orphanLogs(201) != 0 { t.Fatalf("库存不足留下了孤立流水") } // 6) 仓库停用:改变库存的入库请求回 409,不落任何单据。 exec(`UPDATE warehouses SET status='inactive' WHERE organization_id=201`) logsBefore = countLogs(t, db, 201) expectStatus(request("POST", "/api/inbound", `{"handler":"h","supplier":"s","items":[{"material_id":201,"quantity":1}]}`, 201), 409) if got := orderCount("inbound_orders", 201); got != 0 { t.Fatalf("仓库停用留下了半张入库单: %d 张", got) } if countLogs(t, db, 201) != logsBefore || orphanLogs(201) != 0 { t.Fatalf("仓库停用留下了孤立流水") } if got := balance(201, 201); got != aBalanceSteps[4].want { t.Fatalf("仓库停用后 A 余额=%s, want %s", got, aBalanceSteps[4].want) } if got := balance(202, 202); got != "50.000000" { t.Fatalf("失败操作影响了 B 余额: %s", got) } } func countLogs(t *testing.T, db *sql.DB, organizationID int64) int { t.Helper() var count int if err := db.QueryRow(`SELECT COUNT(*) FROM stock_logs WHERE organization_id=?`, organizationID).Scan(&count); err != nil { t.Fatal(err) } return count }