material_manage_system/internal/service/application_organization_db_test.go

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