package service import ( "errors" "sort" "time" "github.com/shopspring/decimal" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/repository" ) var ErrInsufficientStock = errors.New("insufficient stock") type OutboundService struct { outboundRepo *repository.OutboundRepo materialSvc *MaterialService stockRepo *repository.StockRepo } func NewOutboundService() *OutboundService { return &OutboundService{ outboundRepo: repository.NewOutboundRepo(), materialSvc: NewMaterialService(), stockRepo: repository.NewStockRepo(), } } func (s *OutboundService) Create(organizationID int64, req *model.CreateOutboundRequest, operator string) (*model.OutboundOrder, error) { order := &model.OutboundOrder{ OutboundDate: time.Now(), Operator: operator, Handler: operator, Receiver: req.Receiver, Remark: req.Remark, Items: make([]model.OutboundItem, len(req.Items)), } // 库存维度由服务端按当前公司解析:本公司的唯一启用仓库和库位。缺失或停用回 // ErrInventoryScopeUnavailable,Handler 映射为 409。 scope, err := ResolveInventoryScope(organizationID, true) if err != nil { return nil, err } // Validate all items first (outside transaction) for i, item := range req.Items { exists, err := s.materialSvc.Exists(organizationID, item.MaterialID) if err != nil { return nil, err } if !exists { return nil, ErrMaterialNotFound } active, err := s.materialSvc.IsActive(organizationID, item.MaterialID) if err != nil { return nil, err } if !active { return nil, ErrMaterialObsolete } currentStock, err := s.stockRepo.GetStock(scope, item.MaterialID) if err != nil { return nil, err } if currentStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) { return nil, ErrInsufficientStock } order.Items[i] = model.OutboundItem{ MaterialID: item.MaterialID, Quantity: item.Quantity, } } // Start transaction for stock update (prevents race condition) tx, err := repository.BeginTx() if err != nil { return nil, err } defer tx.Rollback() // 按物料 ID 升序条件扣减,避免并发事务以不同顺序加锁造成死锁;条件扣减只在余额 // 存在且足够时更新,余额不存在按 0 处理,因此不会透支、也不会锁到别家公司的余额。 itemIDs := make([]int64, len(order.Items)) for i := range order.Items { itemIDs[i] = order.Items[i].MaterialID } for _, idx := range materialSortedIndexes(itemIDs) { item := &order.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 } return nil, err } } // Create outbound order err = s.outboundRepo.CreateTx(tx, order) if err != nil { return nil, err } // Update stock and create logs within transaction for i := range order.Items { item := &order.Items[i] material, err := s.materialSvc.GetByID(organizationID, item.MaterialID) if err != nil { return nil, err } item.MaterialCode = material.MaterialCode item.MaterialName = material.Name // Create stock log within transaction stockLog := &model.StockLog{ OrganizationID: scope.OrganizationID, WarehouseID: scope.WarehouseID, LocationID: scope.LocationID, MaterialID: item.MaterialID, Type: "out", Quantity: item.Quantity, RelatedOrderType: "outbound", RelatedOrderID: order.ID, } err = s.stockRepo.CreateLogTx(tx, stockLog) if err != nil { return nil, err } } // Commit transaction if err = tx.Commit(); err != nil { return nil, err } order.Status = "completed" return order, nil } // materialSortedIndexes 返回按物料 ID 升序排列的下标,用于固定同一维度下多物料的 // 加锁顺序,避免并发事务互相等待造成死锁。 func materialSortedIndexes(materialIDs []int64) []int { indexes := make([]int, len(materialIDs)) for i := range materialIDs { indexes[i] = i } sort.SliceStable(indexes, func(a, b int) bool { return materialIDs[indexes[a]] < materialIDs[indexes[b]] }) return indexes } func (s *OutboundService) GetByID(id int64) (*model.OutboundOrder, error) { return s.outboundRepo.GetByID(id) } func (s *OutboundService) List(search string, limit, offset int) ([]model.OutboundOrder, int64, error) { return s.outboundRepo.List(search, limit, offset) } func (s *OutboundService) GetByMaterialID(materialID int64) ([]model.OutboundRecord, error) { return s.outboundRepo.GetByMaterialID(materialID) } func (s *OutboundService) Delete(organizationID, id int64) error { // Get the outbound order with items order, err := s.outboundRepo.GetByID(id) if err != nil { return err } // 冲销必须在当前公司启用的库存维度上进行。 scope, err := ResolveInventoryScope(organizationID, true) if err != nil { return err } // Start transaction tx, err := repository.BeginTx() if err != nil { return err } defer tx.Rollback() // 按物料 ID 升序把库存加回,固定加锁顺序。 itemIDs := make([]int64, len(order.Items)) for i := range order.Items { itemIDs[i] = order.Items[i].MaterialID } for _, idx := range materialSortedIndexes(itemIDs) { item := order.Items[idx] err = s.stockRepo.UpdateStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(item.Quantity))) if err != nil { return err } } // Delete stock logs within transaction err = s.stockRepo.DeleteLogsTx(tx, "outbound", id) if err != nil { return err } // Delete the outbound order (items will be cascade deleted) err = s.outboundRepo.DeleteTx(tx, id) if err != nil { return err } // Commit transaction if err = tx.Commit(); err != nil { return err } return nil } func (s *OutboundService) Update(organizationID, id int64, req *model.UpdateOutboundRequest, operator string) (*model.OutboundOrder, error) { // Get old order data oldOrder, err := s.outboundRepo.GetByID(id) if err != nil { return nil, err } // 库存维度由服务端按当前公司解析。 scope, err := ResolveInventoryScope(organizationID, true) if err != nil { return nil, err } // Validate all new items first (outside transaction) for _, item := range req.Items { exists, err := s.materialSvc.Exists(organizationID, item.MaterialID) if err != nil { return nil, err } if !exists { return nil, ErrMaterialNotFound } active, err := s.materialSvc.IsActive(organizationID, item.MaterialID) if err != nil { return nil, err } if !active { return nil, ErrMaterialObsolete } currentStock, err := s.stockRepo.GetStock(scope, item.MaterialID) if err != nil { return nil, err } // Calculate effective stock: current + old outbound quantity - new outbound quantity oldQty := 0 for _, oldItem := range oldOrder.Items { if oldItem.MaterialID == item.MaterialID { oldQty = oldItem.Quantity break } } effectiveStock := currentStock.Add(decimal.NewFromInt(int64(oldQty))) if effectiveStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) { return nil, ErrInsufficientStock } } // Start transaction tx, err := repository.BeginTx() if err != nil { return nil, err } defer tx.Rollback() // Delete old stock logs err = s.stockRepo.DeleteLogsTx(tx, "outbound", id) if err != nil { return nil, err } // Update order in repository // 客户端不能指定操作人;编辑也由当前登录用户接管操作人和兼容字段 handler。 req.Handler = operator req.Operator = operator err = s.outboundRepo.UpdateTx(tx, id, req) if err != nil { return nil, err } // 旧明细(冲销)与新明细(扣减)合并后,按物料 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 } 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 } } 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: materialID, Type: "out", Quantity: item.Quantity, RelatedOrderType: "outbound", RelatedOrderID: id, } err = s.stockRepo.CreateLogTx(tx, stockLog) if err != nil { return nil, err } } } // Commit transaction if err = tx.Commit(); err != nil { return nil, err } // Get updated order with material details updatedOrder, err := s.outboundRepo.GetByID(id) if err != nil { return nil, err } updatedOrder.Status = "completed" return updatedOrder, nil }