package service import ( "errors" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "github.com/wmms/wmms/internal/model" ) func applicationHeadColumns() []string { return []string{"id", "organization_id", "user_id", "purpose", "status", "rejection_reason", "approved_by", "created_at", "updated_at", "username"} } func applicationItemColumns() []string { return []string{"id", "application_id", "material_id", "quantity", "material_code", "name"} } // 普通审批(不扣库存)沿用已有规则:只要求公司授权、状态待审和相关物料合法,公司没有 // 启用仓库也必须能审批,不能因为无法解析库存维度而返回 ErrInventoryScopeUnavailable。 // 这里只登记审批必需的查询;若代码解析仓库 scope,sqlmock 会因未预期语句失败。 func TestApproveWithoutAutoOutboundDoesNotRequireInventoryScope(t *testing.T) { mock := outboundMock(t) now := time.Now() mock.ExpectBegin() mock.ExpectQuery("SELECT a.id, a.organization_id, a.user_id").WithArgs(int64(1), int64(1)).WillReturnRows( sqlmock.NewRows(applicationHeadColumns()). AddRow(1, 1, 2, "领用", model.ApplicationStatusPending, "", 0, now, now, "alice")) mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1), int64(1)).WillReturnRows( sqlmock.NewRows(applicationItemColumns()).AddRow(11, 1, 7, 2, "M-1", "Material")) mock.ExpectExec("UPDATE material_applications SET status").WithArgs(model.ApplicationStatusApproved, int64(9), int64(1), int64(1), model.ApplicationStatusPending).WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() if err := NewApplicationService().Approve(1, 1, 9, false); err != nil { t.Fatalf("普通审批不应要求仓库配置,got %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } // 自动出库审批在同一事务内解析本公司 scope、条件扣减余额、创建带三维度的出库单和流水, // 并带原状态条件改申请状态;所有 SQL 都必须带对组织与维度参数。 func TestApproveAutoOutboundWritesScopeAndDeductsStock(t *testing.T) { mock := outboundMock(t) now := time.Now() mock.ExpectBegin() mock.ExpectQuery("SELECT a.id, a.organization_id, a.user_id").WithArgs(int64(1), int64(1)).WillReturnRows( sqlmock.NewRows(applicationHeadColumns()). AddRow(1, 1, 2, "领用", model.ApplicationStatusPending, "", 0, now, now, "alice")) mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1), int64(1)).WillReturnRows( sqlmock.NewRows(applicationItemColumns()).AddRow(11, 1, 7, 2, "M-1", "Material")) // ResolveInventoryScope:公司 1 的启用仓库 10、库位 20。 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"}). AddRow(10, 1, "W", "仓库", "active", 20, "L", "库位", "active")) mock.ExpectQuery("SELECT username FROM users").WithArgs(int64(9)).WillReturnRows(sqlmock.NewRows([]string{"username"}).AddRow("approver")) // 条件扣减带 scope 的三维度,余额不足会返回 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, 1)) // 出库单头表显式写入公司、仓库、库位。 mock.ExpectExec("INSERT INTO outbound_orders").WithArgs(int64(1), int64(10), int64(20), sqlmock.AnyArg(), "approver", "approver", "alice", "").WillReturnResult(sqlmock.NewResult(5, 1)) mock.ExpectExec("UPDATE outbound_orders SET outbound_no").WithArgs("OUT-00000005", int64(5)).WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec("INSERT INTO outbound_items").WithArgs(int64(5), int64(7), 2).WillReturnResult(sqlmock.NewResult(1, 1)) // 库存流水显式写入三维度。 mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "out", 2, "application", int64(1)).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec("UPDATE material_applications SET status").WithArgs(model.ApplicationStatusApproved, int64(9), int64(1), int64(1), model.ApplicationStatusPending).WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectCommit() if err := NewApplicationService().Approve(1, 1, 9, true); err != nil { t.Fatalf("自动出库审批失败: %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } // 自动出库遇到余额不足时,事务必须整体回滚:状态不落库、不出单、不生成流水。 func TestApproveAutoOutboundRollsBackWhenStockInsufficient(t *testing.T) { mock := outboundMock(t) now := time.Now() mock.ExpectBegin() mock.ExpectQuery("SELECT a.id, a.organization_id, a.user_id").WithArgs(int64(1), int64(1)).WillReturnRows( sqlmock.NewRows(applicationHeadColumns()). AddRow(1, 1, 2, "领用", model.ApplicationStatusPending, "", 0, now, now, "alice")) mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1), int64(1)).WillReturnRows( sqlmock.NewRows(applicationItemColumns()).AddRow(11, 1, 7, 2, "M-1", "Material")) 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"}). AddRow(10, 1, "W", "仓库", "active", 20, "L", "库位", "active")) mock.ExpectQuery("SELECT username FROM users").WithArgs(int64(9)).WillReturnRows(sqlmock.NewRows([]string{"username"}).AddRow("approver")) // 条件扣减影响 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 := NewApplicationService().Approve(1, 1, 9, true) if !errors.Is(err, ErrInsufficientStock) { t.Fatalf("got %v, want ErrInsufficientStock", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } // 别家公司的申请在锁定阶段命中空行,回 ErrApplicationNotFound,不解析 scope、不落任何单据。 func TestApproveCrossOrganizationReturnsNotFound(t *testing.T) { mock := outboundMock(t) mock.ExpectBegin() mock.ExpectQuery("SELECT a.id, a.organization_id, a.user_id").WithArgs(int64(9), int64(1)).WillReturnRows(sqlmock.NewRows(applicationHeadColumns())) mock.ExpectRollback() err := NewApplicationService().Approve(1, 9, 3, true) if !errors.Is(err, ErrApplicationNotFound) { t.Fatalf("got %v, want ErrApplicationNotFound", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }