material_manage_system/internal/service/outbound_test.go

152 lines
7.6 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 (
"errors"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/pkg/database"
)
func outboundMock(t *testing.T) sqlmock.Sqlmock {
t.Helper()
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
previous := database.DB
database.DB = db
t.Cleanup(func() { database.DB = previous; db.Close() })
return mock
}
func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) {
for i := 0; i < 2; i++ {
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), id).
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(id, 1, "M-1", "Material", nil, "", nil, 1, 1, "pc", "active", 1, time.Now(), time.Now()))
}
}
// expectStockScope 让 ResolveInventoryScope 解析出公司 1 的仓库 10、库位 20。
func expectStockScope(mock sqlmock.Sqlmock, orgID int64) {
mock.ExpectQuery("SELECT w.id, w.organization_id").WithArgs(orgID).WillReturnRows(
sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "status", "location_id", "location_code", "location_name", "location_status"}).
AddRow(10, orgID, "W", "仓库", "active", 20, "L", "库位", "active"))
}
func createOutboundRequest() *model.CreateOutboundRequest {
req := &model.CreateOutboundRequest{Receiver: "receiver"}
req.Items = append(req.Items, struct {
MaterialID int64 `json:"material_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"`
}{MaterialID: 7, Quantity: 2})
return req
}
func TestOutboundCreateRollsBackWhenLockedStockRunsOut(t *testing.T) {
mock := outboundMock(t)
expectStockScope(mock, 1)
expectActiveMaterial(mock, 7)
// 事务前预检看到 2,够本次出库。
mock.ExpectQuery("SELECT COALESCE\\(SUM\\(on_hand\\), 0\\) FROM inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
mock.ExpectBegin()
// 事务内条件扣减影响 0 行:并发下余额已被别人扣走,必须整体回滚,不能透支。
mock.ExpectExec("UPDATE inventory_balances SET on_hand = on_hand").WithArgs("2", int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectRollback()
_, err := NewOutboundService().Create(1, createOutboundRequest(), "operator")
if !errors.Is(err, ErrInsufficientStock) {
t.Fatalf("got %v, want insufficient stock", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestOutboundCreateChecksStockBeforeTransaction(t *testing.T) {
mock := outboundMock(t)
expectStockScope(mock, 1)
expectActiveMaterial(mock, 7)
mock.ExpectQuery("SELECT COALESCE\\(SUM\\(on_hand\\), 0\\) FROM inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("1"))
_, err := NewOutboundService().Create(1, createOutboundRequest(), "operator")
if !errors.Is(err, ErrInsufficientStock) {
t.Fatalf("got %v, want insufficient stock", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestOutboundCreateRollsBackWhenMaterialReadFailsAfterOrderInsert(t *testing.T) {
mock := outboundMock(t)
expectStockScope(mock, 1)
expectActiveMaterial(mock, 7)
mock.ExpectQuery("SELECT COALESCE\\(SUM\\(on_hand\\), 0\\) FROM inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
mock.ExpectBegin()
mock.ExpectExec("UPDATE inventory_balances SET on_hand = on_hand").WithArgs("2", int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO outbound_orders").WillReturnResult(sqlmock.NewResult(11, 1))
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(12, 1))
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), int64(7)).WillReturnError(errors.New("material read failed"))
mock.ExpectRollback()
_, err := NewOutboundService().Create(1, createOutboundRequest(), "operator")
if err == nil || err.Error() != "material read failed" {
t.Fatalf("got %v, want material read failure", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestOutboundUpdateDoesNotCountRestoredStockTwice(t *testing.T) {
mock := outboundMock(t)
now := time.Now()
mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(11)).WillReturnRows(
sqlmock.NewRows([]string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "created_at", "updated_at"}).AddRow(11, "OUT-11", "operator", now, "operator", "receiver", "", now, now))
mock.ExpectQuery("SELECT oi.id, oi.outbound_order_id").WithArgs(int64(11)).WillReturnRows(
sqlmock.NewRows([]string{"id", "outbound_order_id", "material_id", "quantity", "material_code", "name", "status"}).AddRow(12, 11, 7, 2, "M-1", "Material", "active"))
expectStockScope(mock, 1)
expectActiveMaterial(mock, 7)
// 预检:当前 2 + 旧单已扣 2 = 4,够新单的 4。
mock.ExpectQuery("SELECT COALESCE\\(SUM\\(on_hand\\), 0\\) FROM inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2"))
mock.ExpectBegin()
mock.ExpectExec("DELETE FROM stock_logs").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("DELETE FROM outbound_items").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(13, 1))
// 先冲销旧单扣的 2(按物料 ID 排序后,冲销与扣减在同一物料上连续进行)。
mock.ExpectExec("INSERT INTO inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1))
// 新单扣 4:冲销后余额仍不足,条件扣减影响 0 行 → 回滚。
mock.ExpectExec("UPDATE inventory_balances SET on_hand = on_hand").WithArgs("4", int64(1), int64(10), int64(20), int64(7), "4").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectRollback()
req := &model.UpdateOutboundRequest{Receiver: "receiver"}
req.Items = append(req.Items, struct {
MaterialID int64 `json:"material_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,gt=0"`
}{MaterialID: 7, Quantity: 4})
_, err := NewOutboundService().Update(1, 11, req, "operator")
if !errors.Is(err, ErrInsufficientStock) {
t.Fatalf("got %v, want insufficient stock", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 公司未配置启用仓库/库位时,改变库存的出库操作必须直接失败而不是回退默认公司。
func TestOutboundCreateRejectsWhenInventoryScopeUnavailable(t *testing.T) {
mock := outboundMock(t)
// List 查不到仓库:Summary 返回 nil。
mock.ExpectQuery("SELECT w.id, w.organization_id").WithArgs(int64(1)).WillReturnRows(
sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "status", "location_id", "location_code", "location_name", "location_status"}))
_, err := NewOutboundService().Create(1, createOutboundRequest(), "operator")
if !errors.Is(err, ErrInventoryScopeUnavailable) {
t.Fatalf("got %v, want ErrInventoryScopeUnavailable", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}