diff --git a/internal/handler/api.go b/internal/handler/api.go index caab824c..5ef95592 100644 --- a/internal/handler/api.go +++ b/internal/handler/api.go @@ -966,6 +966,19 @@ func (h *Handler) UpdateWarningStock(c *gin.Context) { if !ok { return } + + // 先校验物料归属:预警规则的外键指向全局 materials(id),不校验的话写别家公司的 + // material_id 会静默成功。跨公司一律 404,与库存详情、流水保持一致。 + exists, err := h.stockSvc.MaterialExists(orgID, materialID) + if err != nil { + response.InternalError(c, err.Error()) + return + } + if !exists { + response.NotFound(c, "Material not found") + return + } + // 改变库存域的写操作必须落在当前公司启用的仓库、库位上;缺失或停用时回 409 并给出 // 具体配置提示,而不是写进默认公司或静默成功。 scope, err := service.ResolveInventoryScope(orgID, true) diff --git a/internal/handler/stock_organization_test.go b/internal/handler/stock_organization_test.go index 8a7d59cd..6aa2282c 100644 --- a/internal/handler/stock_organization_test.go +++ b/internal/handler/stock_organization_test.go @@ -114,7 +114,10 @@ func TestStockWarningWriteReturnsConflictWhenScopeUnavailable(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 1) - // 该公司没有仓库:Summary 返回 nil。 + // 物料属于本公司(先通过归属校验),但公司没有仓库:Summary 返回 nil。 + mock.ExpectQuery(`(?s)`+stockByMaterialPattern).WithArgs(int64(1), int64(5)). + WillReturnRows(sqlmock.NewRows(stockMaterialColumns()). + AddRow(5, "MAT-005", "轴承", "6204", "个", "0.000000", "0.000000", true, true, "A01", "A01-01")) mock.ExpectQuery(stockWarehousePattern).WithArgs(int64(1)). WillReturnRows(sqlmock.NewRows(warehouseSummaryColumns())) @@ -129,3 +132,23 @@ func TestStockWarningWriteReturnsConflictWhenScopeUnavailable(t *testing.T) { t.Fatal(err) } } + +// TestStockWarningWriteRejectsAnotherOrganizationsMaterial:预警规则的 material_id 外键指向 +// 全局 materials(id),写别家公司的物料必须在归属校验阶段回 404,且不执行任何 upsert。 +func TestStockWarningWriteRejectsAnotherOrganizationsMaterial(t *testing.T) { + mock := contextMock(t) + expectContextPrincipal(mock, "warehouse_admin") + expectMaterialOrgAccess(mock, 1) + // 物料 7 属于公司 2:带 organization_id=1 的读取查不到。 + mock.ExpectQuery(`(?s)`+stockByMaterialPattern).WithArgs(int64(1), int64(7)). + WillReturnRows(sqlmock.NewRows(stockMaterialColumns())) + + w := newStockRequest(t, "PUT", "/api/stock/7/warning", `{"warning_stock":10}`, 1) + if w.Code != 404 { + t.Fatalf("跨公司物料写预警应当返回 404,实际 status=%d body=%s", w.Code, w.Body.String()) + } + // 只登记了归属校验查询:一旦越过它去 upsert,sqlmock 会因未预期语句报错。 + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/service/application.go b/internal/service/application.go index 943a6552..bcf61791 100644 --- a/internal/service/application.go +++ b/internal/service/application.go @@ -104,25 +104,24 @@ func (s *ApplicationService) Approve(organizationID, id int64, approverID int64, return ErrApplicationNotPending } - // 库存维度由服务端按当前公司解析;审批自动出库会扣减本公司库存。 - scope, err := ResolveInventoryScope(organizationID, true) - if err != nil { - return err - } - - // Check stock for all items - for _, item := range app.Items { - stock, err := s.stockRepo.GetStock(scope, item.MaterialID) - if err != nil { - return ErrMaterialNotFound - } - if stock.LessThan(decimal.NewFromInt(int64(item.Quantity))) { - return ErrInsufficientStock - } - } - // Auto outbound if requested if autoOutbound { + // 只有自动出库才会改变库存,才需要解析本公司启用的仓库、库位并做库存检查。 + // 普通审批(不扣库存)沿用已有规则:只要求公司授权和相关物料合法,不要求仓库配置。 + scope, err := ResolveInventoryScope(organizationID, true) + if err != nil { + return err + } + for _, item := range app.Items { + stock, err := s.stockRepo.GetStock(scope, item.MaterialID) + if err != nil { + return ErrMaterialNotFound + } + if stock.LessThan(decimal.NewFromInt(int64(item.Quantity))) { + return ErrInsufficientStock + } + } + // Start transaction tx, err := repository.BeginTx() if err != nil { @@ -245,9 +244,13 @@ func (s *ApplicationService) Cancel(id int64, userID int64) error { } func (s *ApplicationService) GetMaterialStock(organizationID, materialID int64) (decimal.Decimal, error) { - // 只读查询:停用的仓库、库位仍返回其维度,便于核验历史余额。 + // 只读查询:停用的仓库、库位仍返回其维度,便于核验历史余额;公司没有仓库时按约束 + // "无余额返回 0" 处理,而不是报错。 scope, err := ResolveInventoryScope(organizationID, false) if err != nil { + if errors.Is(err, ErrInventoryScopeUnavailable) { + return decimal.Zero, nil + } return decimal.Zero, err } return s.stockRepo.GetStock(scope, materialID) diff --git a/internal/service/application_test.go b/internal/service/application_test.go new file mode 100644 index 00000000..a2aec035 --- /dev/null +++ b/internal/service/application_test.go @@ -0,0 +1,31 @@ +package service + +import ( + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/wmms/wmms/internal/model" +) + +// 普通审批(不扣库存)沿用已有规则:只要求公司授权、状态待审和相关物料合法,公司没有 +// 启用仓库也必须能审批,不能因为无法解析库存维度而返回 ErrInventoryScopeUnavailable。 +// 这里只登记审批必需的查询;若代码先解析仓库 scope,sqlmock 会因未预期语句失败。 +func TestApproveWithoutAutoOutboundDoesNotRequireInventoryScope(t *testing.T) { + mock := outboundMock(t) + now := time.Now() + mock.ExpectQuery("SELECT a.id, a.user_id, a.purpose").WithArgs(int64(1)).WillReturnRows( + sqlmock.NewRows([]string{"id", "user_id", "purpose", "status", "rejection_reason", "approved_by", "created_at", "updated_at", "username"}). + AddRow(1, 2, "领用", model.ApplicationStatusPending, "", 0, now, now, "alice")) + mock.ExpectQuery("SELECT ai.id, ai.application_id").WithArgs(int64(1)).WillReturnRows( + sqlmock.NewRows([]string{"id", "application_id", "material_id", "quantity", "material_code", "name"}). + AddRow(11, 1, 7, 2, "M-1", "Material")) + mock.ExpectExec("UPDATE material_applications SET status").WithArgs(model.ApplicationStatusApproved, int64(9), int64(1)).WillReturnResult(sqlmock.NewResult(0, 1)) + + if err := NewApplicationService().Approve(1, 1, 9, false); err != nil { + t.Fatalf("普通审批不应要求仓库配置,got %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/service/inbound.go b/internal/service/inbound.go index 1512e949..0f067a38 100644 --- a/internal/service/inbound.go +++ b/internal/service/inbound.go @@ -245,18 +245,6 @@ func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInbou } }() - // Reverse stock for old items (subtract what was previously added) - for _, item := range oldOrder.Items { - _, err = s.stockRepo.GetStockForUpdateTx(tx, scope, item.MaterialID) - if err != nil { - return nil, err - } - err = s.stockRepo.UpdateStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(-item.Quantity))) - if err != nil { - return nil, err - } - } - // Delete old stock logs err = s.stockRepo.DeleteLogsTx(tx, "inbound", id) if err != nil { @@ -269,40 +257,71 @@ func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInbou return nil, err } - // Add stock for new items and create logs - for _, item := range req.Items { - _, err = s.stockRepo.GetStockForUpdateTx(tx, scope, item.MaterialID) - if err != nil { - return nil, err - } - err = s.stockRepo.UpdateStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(item.Quantity))) - if err != nil { - return nil, err - } - - // 阈值随明细一起更新,nil 表示不改动已存的值,避免编辑旧入库单时把刚调好的 - // 阈值覆盖回旧数;启用状态同理,nil 表示不改动。 - if item.WarningStock != nil { - err = s.stockRepo.UpdateWarningStockTx(tx, scope, item.MaterialID, *item.WarningStock, - resolveWarningEnabled(item.WarningEnabled)) + // 旧明细(冲销)与新明细(补货)合并后按物料 ID 统一升序处理,逐物料先冲销旧量、 + // 再补新量。两个并发的 Update 交换物料时也会以相同全局顺序加锁,不会死锁。 + oldQtyByMaterial := make(map[int64]int) + involved := make(map[int64]bool) + for _, item := range oldOrder.Items { + oldQtyByMaterial[item.MaterialID] += item.Quantity + involved[item.MaterialID] = true + } + newItemIndexes := make(map[int64][]int) + for i := range req.Items { + materialID := req.Items[i].MaterialID + newItemIndexes[materialID] = append(newItemIndexes[materialID], i) + involved[materialID] = true + } + materialIDs := make([]int64, 0, len(involved)) + for materialID := range involved { + materialIDs = append(materialIDs, materialID) + } + for _, idx := range materialSortedIndexes(materialIDs) { + materialID := materialIDs[idx] + if qty := oldQtyByMaterial[materialID]; qty > 0 { + _, err = s.stockRepo.GetStockForUpdateTx(tx, scope, materialID) + if err != nil { + return nil, err + } + err = s.stockRepo.UpdateStockTx(tx, scope, materialID, decimal.NewFromInt(int64(-qty))) if err != nil { return nil, err } } + for _, i := range newItemIndexes[materialID] { + item := req.Items[i] + _, err = s.stockRepo.GetStockForUpdateTx(tx, scope, materialID) + if err != nil { + return nil, err + } + err = s.stockRepo.UpdateStockTx(tx, scope, materialID, decimal.NewFromInt(int64(item.Quantity))) + if err != nil { + return nil, err + } - stockLog := &model.StockLog{ - OrganizationID: scope.OrganizationID, - WarehouseID: scope.WarehouseID, - LocationID: scope.LocationID, - MaterialID: item.MaterialID, - Type: "in", - Quantity: item.Quantity, - RelatedOrderType: "inbound", - RelatedOrderID: id, - } - err = s.stockRepo.CreateLogTx(tx, stockLog) - if err != nil { - return nil, err + // 阈值随明细一起更新,nil 表示不改动已存的值,避免编辑旧入库单时把刚调好的 + // 阈值覆盖回旧数;启用状态同理,nil 表示不改动。 + if item.WarningStock != nil { + err = s.stockRepo.UpdateWarningStockTx(tx, scope, materialID, *item.WarningStock, + resolveWarningEnabled(item.WarningEnabled)) + if err != nil { + return nil, err + } + } + + stockLog := &model.StockLog{ + OrganizationID: scope.OrganizationID, + WarehouseID: scope.WarehouseID, + LocationID: scope.LocationID, + MaterialID: materialID, + Type: "in", + Quantity: item.Quantity, + RelatedOrderType: "inbound", + RelatedOrderID: id, + } + err = s.stockRepo.CreateLogTx(tx, stockLog) + if err != nil { + return nil, err + } } } diff --git a/internal/service/outbound.go b/internal/service/outbound.go index a87b879f..66c05583 100644 --- a/internal/service/outbound.go +++ b/internal/service/outbound.go @@ -273,20 +273,6 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb } defer tx.Rollback() - // Reverse stock for old items (add back what was previously subtracted), - // 按物料 ID 升序固定加锁顺序。 - oldItemIDs := make([]int64, len(oldOrder.Items)) - for i := range oldOrder.Items { - oldItemIDs[i] = oldOrder.Items[i].MaterialID - } - for _, idx := range materialSortedIndexes(oldItemIDs) { - item := oldOrder.Items[idx] - err = s.stockRepo.UpdateStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(item.Quantity))) - if err != nil { - return nil, err - } - } - // Delete old stock logs err = s.stockRepo.DeleteLogsTx(tx, "outbound", id) if err != nil { @@ -302,34 +288,57 @@ func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutb return nil, err } - // Deduct stock for new items (conditional, ordered) and create logs - newItemIDs := make([]int64, len(req.Items)) - for i := range req.Items { - newItemIDs[i] = req.Items[i].MaterialID + // 旧明细(冲销)与新明细(扣减)合并后,按物料 ID 统一升序处理,逐物料先冲销旧量、 + // 再条件扣减新量。两个并发的 Update 即便交换物料(tx1 旧={5}→新={3}、tx2 旧={3}→新={5}), + // 也会以相同的全局顺序加锁,不会反过来锁住彼此持有的行造成死锁。 + oldQtyByMaterial := make(map[int64]int) + involved := make(map[int64]bool) + for _, item := range oldOrder.Items { + oldQtyByMaterial[item.MaterialID] += item.Quantity + involved[item.MaterialID] = true } - for _, idx := range materialSortedIndexes(newItemIDs) { - item := req.Items[idx] - err = s.stockRepo.DeductStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(item.Quantity))) - if err != nil { - if errors.Is(err, repository.ErrInsufficientStock) { - return nil, ErrInsufficientStock + newItemIndexes := make(map[int64][]int) + for i := range req.Items { + materialID := req.Items[i].MaterialID + newItemIndexes[materialID] = append(newItemIndexes[materialID], i) + involved[materialID] = true + } + materialIDs := make([]int64, 0, len(involved)) + for materialID := range involved { + materialIDs = append(materialIDs, materialID) + } + for _, idx := range materialSortedIndexes(materialIDs) { + materialID := materialIDs[idx] + if qty := oldQtyByMaterial[materialID]; qty > 0 { + err = s.stockRepo.UpdateStockTx(tx, scope, materialID, decimal.NewFromInt(int64(qty))) + if err != nil { + return nil, err } - return nil, err } + for _, i := range newItemIndexes[materialID] { + item := req.Items[i] + err = s.stockRepo.DeductStockTx(tx, scope, materialID, decimal.NewFromInt(int64(item.Quantity))) + if err != nil { + if errors.Is(err, repository.ErrInsufficientStock) { + return nil, ErrInsufficientStock + } + return nil, err + } - stockLog := &model.StockLog{ - OrganizationID: scope.OrganizationID, - WarehouseID: scope.WarehouseID, - LocationID: scope.LocationID, - MaterialID: item.MaterialID, - Type: "out", - Quantity: item.Quantity, - RelatedOrderType: "outbound", - RelatedOrderID: id, - } - err = s.stockRepo.CreateLogTx(tx, stockLog) - if err != nil { - return nil, err + stockLog := &model.StockLog{ + OrganizationID: scope.OrganizationID, + WarehouseID: scope.WarehouseID, + LocationID: scope.LocationID, + MaterialID: materialID, + Type: "out", + Quantity: item.Quantity, + RelatedOrderType: "outbound", + RelatedOrderID: id, + } + err = s.stockRepo.CreateLogTx(tx, stockLog) + if err != nil { + return nil, err + } } } diff --git a/internal/service/outbound_test.go b/internal/service/outbound_test.go index 49e60919..d2bbd33c 100644 --- a/internal/service/outbound_test.go +++ b/internal/service/outbound_test.go @@ -112,13 +112,13 @@ func TestOutboundUpdateDoesNotCountRestoredStockTwice(t *testing.T) { // 预检:当前 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("INSERT INTO inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1)) 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)) - // 新单扣 4:加回后余额不足,条件扣减影响 0 行 → 回滚。 + // 先冲销旧单扣的 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"}