package service import ( "errors" "github.com/shopspring/decimal" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/internal/repository" ) var ( ErrApplicationNotFound = errors.New("application not found") ErrCannotCancelApplication = errors.New("cannot cancel application") ErrApplicationNotPending = errors.New("application is not pending") ) type ApplicationService struct { appRepo *repository.ApplicationRepository materialSvc *MaterialService stockRepo *repository.StockRepo } func NewApplicationService() *ApplicationService { return &ApplicationService{ appRepo: repository.NewApplicationRepository(), materialSvc: NewMaterialService(), stockRepo: repository.NewStockRepo(), } } func (s *ApplicationService) Create(organizationID, userID int64, req *model.CreateApplicationRequest) (*model.MaterialApplication, error) { // Build items from request var items []model.ApplicationItem for _, item := range req.Items { exists, err := s.materialSvc.Exists(organizationID, item.MaterialID) if err != nil || !exists { return nil, ErrMaterialNotFound } active, err := s.materialSvc.IsActive(organizationID, item.MaterialID) if err != nil { return nil, err } if !active { return nil, ErrMaterialObsolete } items = append(items, model.ApplicationItem{ MaterialID: item.MaterialID, Quantity: item.Quantity, }) } app := &model.MaterialApplication{ UserID: userID, Purpose: req.Purpose, Status: model.ApplicationStatusPending, Items: items, } if err := s.appRepo.Create(app); err != nil { return nil, err } // Get full details return s.appRepo.GetByID(app.ID) } func (s *ApplicationService) GetByID(id int64) (*model.MaterialApplication, error) { app, err := s.appRepo.GetByID(id) if err != nil { return nil, ErrApplicationNotFound } return app, nil } func (s *ApplicationService) ListByUser(userID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) { apps, total, err := s.appRepo.ListByUserID(userID, status, limit, offset) if err != nil { return nil, 0, err } if apps == nil { apps = []model.MaterialApplication{} } return apps, total, nil } func (s *ApplicationService) ListAll(status string, limit, offset int) ([]model.MaterialApplication, int, error) { apps, total, err := s.appRepo.ListAllApplications(status, limit, offset) if err != nil { return nil, 0, err } if apps == nil { apps = []model.MaterialApplication{} } return apps, total, nil } func (s *ApplicationService) Approve(organizationID, id int64, approverID int64, autoOutbound bool) error { app, err := s.appRepo.GetByID(id) if err != nil { return ErrApplicationNotFound } if app.Status != model.ApplicationStatusPending { return ErrApplicationNotPending } // 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 { return err } // 这里不能靠 err 判断是否回滚:事务内每个 if 里的 err 都是 := 声明的局部变量, // 闭包捕获到的外层 err 始终为 nil,任何一步失败都不会回滚,事务和它持有的行锁 // 会一直挂在连接上不释放。改用标志位,只要没走到提交就一定回滚。 committed := false defer func() { if !committed { tx.Rollback() } }() // Get approver name for handler approverName, _ := s.appRepo.GetApproverName(approverID) // Create outbound order within transaction order := &model.OutboundOrder{ Handler: approverName, Operator: approverName, Receiver: app.Username, Status: "completed", } if err := s.appRepo.CreateOutboundOrderTx(tx, order); err != nil { return err } // Process each item within transaction. 按物料 ID 升序条件扣减,余额不足时整体回滚。 itemIDs := make([]int64, len(app.Items)) for i := range app.Items { itemIDs[i] = app.Items[i].MaterialID } for _, idx := range materialSortedIndexes(itemIDs) { appItem := app.Items[idx] // Deduct stock within transaction if err := s.stockRepo.DeductStockTx(tx, scope, appItem.MaterialID, decimal.NewFromInt(int64(appItem.Quantity))); err != nil { if errors.Is(err, repository.ErrInsufficientStock) { return ErrInsufficientStock } return err } // Create outbound item within transaction item := &model.OutboundItem{ OutboundOrderID: order.ID, MaterialID: appItem.MaterialID, Quantity: appItem.Quantity, } if err := s.appRepo.CreateOutboundItemTx(tx, item); err != nil { return err } // Create stock log within transaction stockLog := &model.StockLog{ OrganizationID: scope.OrganizationID, WarehouseID: scope.WarehouseID, LocationID: scope.LocationID, MaterialID: appItem.MaterialID, Type: "out", Quantity: appItem.Quantity, RelatedOrderType: "application", RelatedOrderID: id, } if err := s.appRepo.CreateStockLogTx(tx, stockLog); err != nil { return err } } // Update status to approved within transaction if err := s.appRepo.ApproveTx(tx, id, approverID); err != nil { return err } // Commit transaction if err = tx.Commit(); err != nil { return err } committed = true } else { // Non-auto-outbound: just approve if err := s.appRepo.Approve(id, approverID); err != nil { return err } } return nil } func (s *ApplicationService) Reject(id int64, reason string) error { app, err := s.appRepo.GetByID(id) if err != nil { return ErrApplicationNotFound } if app.Status != model.ApplicationStatusPending { return ErrApplicationNotPending } return s.appRepo.Reject(id, reason) } func (s *ApplicationService) Cancel(id int64, userID int64) error { app, err := s.appRepo.GetByID(id) if err != nil { return ErrApplicationNotFound } // User can only cancel their own applications if app.UserID != userID { return errors.New("cannot cancel others' applications") } if app.Status != model.ApplicationStatusPending { return ErrCannotCancelApplication } return s.appRepo.Cancel(id) } 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) }