package service import ( "database/sql" "encoding/json" "errors" "strconv" "testing" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/testutil" ) // seedApplicationIsolationDB 在一次性 company_isolation_test_* 库里另外播种两家公司 // (避开 init.sql 自带的公司 1): // // 公司 A=101:物料 101、仓库 101、库位 101、余额 10; // 公司 B=102:物料 102、仓库 102、库位 102、余额 5。 // // 用户 2 是申请人(两家公司都授权),用户 3 是审批人。用来验证自动出库的事务隔离。 func seedApplicationIsolationDB(t *testing.T, db *sql.DB) { t.Helper() mustExec := func(query string) { if _, err := db.Exec(query); err != nil { t.Fatalf("fixture SQL: %v\n%s", err, query) } } mustExec(`INSERT INTO organizations(id,code,name) VALUES (101,'IA','A'),(102,'IB','B')`) mustExec(`INSERT INTO users(id,username,password) VALUES (2,'applicant','x'),(3,'approver','x')`) mustExec(`INSERT INTO material_categories(id,organization_id,code,name) VALUES (101,101,'C','C'),(102,102,'C','C')`) mustExec(`INSERT INTO units(id,organization_id,code,name) VALUES (101,101,'U','U'),(102,102,'U','U')`) mustExec(`INSERT INTO materials(id,organization_id,material_code,name,spec,unit,category_id,base_unit_id) VALUES (101,101,'M','M','','个',101,101),(102,102,'M','M','','个',102,102)`) mustExec(`INSERT INTO warehouses(id,organization_id,code,name,status) VALUES (101,101,'W','W','active'),(102,102,'W','W','active')`) mustExec(`INSERT INTO warehouse_locations(id,warehouse_id,code,name,status) VALUES (101,101,'L','L','active'),(102,102,'L','L','active')`) mustExec(`INSERT INTO inventory_balances(organization_id,warehouse_id,location_id,material_id,on_hand) VALUES (101,101,101,101,10),(102,102,102,102,5)`) } func createApplication(t *testing.T, svc *ApplicationService, organizationID, userID, materialID int64, quantity int) *model.MaterialApplication { t.Helper() var req model.CreateApplicationRequest body := `{"purpose":"领用","items":[{"material_id":` + strconv.FormatInt(materialID, 10) + `,"quantity":` + strconv.Itoa(quantity) + `}]}` if err := json.Unmarshal([]byte(body), &req); err != nil { t.Fatal(err) } app, err := svc.Create(organizationID, userID, &req) if err != nil { t.Fatalf("创建申请失败: %v", err) } return app } func scalarInt64(t *testing.T, db *sql.DB, query string, args ...interface{}) int64 { t.Helper() var v int64 if err := db.QueryRow(query, args...).Scan(&v); err != nil { t.Fatal(err) } return v } func scalarDecimalString(t *testing.T, db *sql.DB, query string, args ...interface{}) string { t.Helper() var v string if err := db.QueryRow(query, args...).Scan(&v); err != nil { t.Fatal(err) } return v } // 端到端验证自动出库审批的事务隔离:A 审批自动出库只改 A 库存、只建 A 单据与流水; // 重复审批只成功一次;库存不足时申请状态、出库单、库存、流水一起回滚。 func TestApproveAutoOutboundTransactionIsolation(t *testing.T) { db := testutil.NewIsolationDB(t) seedApplicationIsolationDB(t, db) svc := NewApplicationService() appA := createApplication(t, svc, 101, 2, 101, 3) // A 审批自动出库成功。 if err := svc.Approve(101, appA.ID, 3, true); err != nil { t.Fatalf("自动出库审批失败: %v", err) } // A 余额 10-3=7;B 完全不受影响。 if got := scalarDecimalString(t, db, `SELECT on_hand FROM inventory_balances WHERE organization_id=101 AND material_id=101`); got != "7.000000" { t.Fatalf("A 余额=%s, want 7.000000", got) } if got := scalarDecimalString(t, db, `SELECT on_hand FROM inventory_balances WHERE organization_id=102 AND material_id=102`); got != "5.000000" { t.Fatalf("B 余额被改动: %s", got) } // 出库单与流水都落在 A 公司、A 的仓库库位。 if got := scalarInt64(t, db, `SELECT COUNT(*) FROM outbound_orders WHERE organization_id=101 AND warehouse_id=101 AND location_id=101`); got != 1 { t.Fatalf("A 出库单=%d, want 1", got) } if got := scalarInt64(t, db, `SELECT COUNT(*) FROM outbound_orders WHERE organization_id=102`); got != 0 { t.Fatalf("B 出现了出库单: %d", got) } if got := scalarInt64(t, db, `SELECT COUNT(*) FROM stock_logs WHERE organization_id=101 AND type='out' AND related_order_type='application'`); got != 1 { t.Fatalf("A 出库流水=%d, want 1", got) } if got := scalarInt64(t, db, `SELECT COUNT(*) FROM stock_logs WHERE organization_id=102`); got != 0 { t.Fatalf("B 出现了流水: %d", got) } if got := scalarInt64(t, db, `SELECT COUNT(*) FROM material_applications WHERE id=? AND status='approved'`, appA.ID); got != 1 { t.Fatalf("申请状态未变为 approved") } // 重复审批只成功一次:状态已非 pending,且不产生第二张出库单、不重复扣减。 if err := svc.Approve(101, appA.ID, 3, true); !errors.Is(err, ErrApplicationNotPending) { t.Fatalf("重复审批必须 ErrApplicationNotPending, got %v", err) } if got := scalarInt64(t, db, `SELECT COUNT(*) FROM outbound_orders WHERE organization_id=101`); got != 1 { t.Fatalf("重复审批产生了第二张出库单: %d", got) } if got := scalarDecimalString(t, db, `SELECT on_hand FROM inventory_balances WHERE organization_id=101 AND material_id=101`); got != "7.000000" { t.Fatalf("重复审批重复扣减: %s", got) } // 库存不足:余额只要 7,却申请 999,审批必须整体回滚。 appShort := createApplication(t, svc, 101, 2, 101, 999) if err := svc.Approve(101, appShort.ID, 3, true); !errors.Is(err, ErrInsufficientStock) { t.Fatalf("库存不足必须 ErrInsufficientStock, got %v", err) } if got := scalarInt64(t, db, `SELECT COUNT(*) FROM material_applications WHERE id=? AND status='pending'`, appShort.ID); got != 1 { t.Fatalf("库存不足后申请状态应保持 pending") } if got := scalarInt64(t, db, `SELECT COUNT(*) FROM outbound_orders WHERE organization_id=101`); got != 1 { t.Fatalf("库存不足回滚不彻底,出库单=%d, want 1", got) } if got := scalarDecimalString(t, db, `SELECT on_hand FROM inventory_balances WHERE organization_id=101 AND material_id=101`); got != "7.000000" { t.Fatalf("库存不足回滚不彻底,余额=%s", got) } } // 新建申请验证全部物料属于当前公司:申请 B 的物料返回 ErrMaterialNotFound,且不落库。 func TestCreateApplicationRejectsCrossCompanyMaterial(t *testing.T) { db := testutil.NewIsolationDB(t) seedApplicationIsolationDB(t, db) svc := NewApplicationService() var req model.CreateApplicationRequest if err := json.Unmarshal([]byte(`{"purpose":"领用","items":[{"material_id":102,"quantity":1}]}`), &req); err != nil { t.Fatal(err) } if _, err := svc.Create(101, 2, &req); !errors.Is(err, ErrMaterialNotFound) { t.Fatalf("跨公司物料必须 ErrMaterialNotFound, got %v", err) } if got := scalarInt64(t, db, `SELECT COUNT(*) FROM material_applications`); got != 0 { t.Fatalf("非法申请落库了: %d", got) } }