152 lines
6.9 KiB
Go
152 lines
6.9 KiB
Go
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)
|
||
}
|
||
}
|