218 lines
11 KiB
Go
218 lines
11 KiB
Go
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)
|
||
}
|
||
}
|