material_manage_system/internal/service/outbound_test.go
2026-10-09 12:21:53 +08:00

218 lines
11 KiB
Go
Raw Permalink 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"
"database/sql/driver"
"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
}
// activeMaterialRow 造一行启用物料,供物料归属/存在性校验与明细补名使用。
func activeMaterialRow(id int64) *sqlmock.Rows {
return 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())
}
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(activeMaterialRow(id))
}
}
// 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()
headColumns := []string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"}
headRow := []driver.Value{11, "OUT-11", "operator", now, "operator", "receiver", "", 1, 10, 20, now, now}
itemColumns := []string{"id", "outbound_order_id", "material_id", "quantity", "material_code", "name", "status"}
itemRow := []driver.Value{12, 11, 7, 2, "M-1", "Material", "active"}
// 事务外先按公司读取原单据(带组织参数)。
mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(11), int64(1)).WillReturnRows(
sqlmock.NewRows(headColumns).AddRow(headRow...))
mock.ExpectQuery("SELECT oi.id, oi.outbound_order_id").WithArgs(int64(11)).WillReturnRows(
sqlmock.NewRows(itemColumns).AddRow(itemRow...))
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()
// 事务内锁定当前公司的原单据(FOR UPDATE),再以锁下的原明细冲销。
mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(11), int64(1)).WillReturnRows(
sqlmock.NewRows(headColumns).AddRow(headRow...))
mock.ExpectQuery("SELECT oi.id, oi.outbound_order_id").WithArgs(int64(11)).WillReturnRows(
sqlmock.NewRows(itemColumns).AddRow(itemRow...))
mock.ExpectExec("DELETE FROM stock_logs").WithArgs(int64(1), "outbound", int64(11)).WillReturnResult(sqlmock.NewResult(0, 1))
// UpdateTx 先按公司确认单据归属,再改头表与明细。
mock.ExpectQuery("SELECT id FROM outbound_orders").WithArgs(int64(1), int64(11)).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(11))
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)
}
}
// 出库单据的三个维度由服务端按当前公司解析后填写,落库 SQL 带上公司、仓库、库位。
func TestOutboundCreateFillsOrderDimensionsFromResolvedScope(t *testing.T) {
mock := outboundMock(t)
// Create 先解析库存维度,再校验明细并预检余额。
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("5"))
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").
WithArgs(int64(1), int64(10), int64(20), sqlmock.AnyArg(), "operator", "operator", "receiver", "").
WillReturnResult(sqlmock.NewResult(11, 1))
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO outbound_items").WithArgs(int64(11), int64(7), 2).WillReturnResult(sqlmock.NewResult(12, 1))
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7))
mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "out", 2, "outbound", int64(11)).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
order, err := NewOutboundService().Create(1, createOutboundRequest(), "operator")
if err != nil {
t.Fatalf("create outbound: %v", err)
}
if order.OrganizationID != 1 || order.WarehouseID != 10 || order.LocationID != 20 {
t.Fatalf("单据维度=%d/%d/%d, want 1/10/20", order.OrganizationID, order.WarehouseID, order.LocationID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// 删除别家公司的单据:锁定当前公司原单据时命中 ErrNoRows,不冲销任何库存。
func TestOutboundDeleteCrossOrganizationReturnsNoRows(t *testing.T) {
mock := outboundMock(t)
mock.ExpectBegin()
mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(9), int64(1)).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectRollback()
err := NewOutboundService().Delete(1, 9)
if err != sql.ErrNoRows {
t.Fatalf("got %v, want sql.ErrNoRows", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}