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) } }