232 lines
9.1 KiB
Go
232 lines
9.1 KiB
Go
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
|
||
}
|