package service import ( "database/sql" "errors" "time" "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") ErrCannotCancelOthers = errors.New("cannot cancel others' applications") ) type ApplicationService struct { appRepo *repository.ApplicationRepository outboundRepo *repository.OutboundRepo materialSvc *MaterialService stockRepo *repository.StockRepo } func NewApplicationService() *ApplicationService { return &ApplicationService{ appRepo: repository.NewApplicationRepository(), outboundRepo: repository.NewOutboundRepo(), 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, }) } // 申请归属取可信上下文里的 organizationID,不取当前用户的默认公司。 app := &model.MaterialApplication{ OrganizationID: organizationID, 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(organizationID, app.ID) } func (s *ApplicationService) GetByID(organizationID, id int64) (*model.MaterialApplication, error) { app, err := s.appRepo.GetByID(organizationID, id) if err != nil { return nil, ErrApplicationNotFound } return app, nil } func (s *ApplicationService) ListByUser(organizationID, userID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) { apps, total, err := s.appRepo.ListByUserID(organizationID, 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(organizationID int64, status string, limit, offset int) ([]model.MaterialApplication, int, error) { apps, total, err := s.appRepo.ListAllApplications(organizationID, 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 { tx, err := repository.BeginTx() if err != nil { return err } // 只要没走到提交就一定回滚,避免锁住的行和部分写入残留。 committed := false defer func() { if !committed { tx.Rollback() } }() // 在同一事务内 SELECT ... FOR UPDATE 锁定当前公司的申请;别家公司或不存在命中 // sql.ErrNoRows,回 404。锁下的状态才是判定依据,并发审批在这里串行化。 app, err := s.appRepo.GetByIDForUpdateTx(tx, organizationID, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrApplicationNotFound } return err } if app.Status != model.ApplicationStatusPending { return ErrApplicationNotPending } if autoOutbound { // 只有自动出库才会改变库存,才需要解析本公司启用的仓库、库位并做库存检查。 // 普通审批(不扣库存)沿用已有规则:只要求公司授权和相关物料合法,不要求仓库配置。 scope, err := ResolveInventoryScope(organizationID, true) if err != nil { return err } // Get approver name for handler approverName, _ := s.appRepo.GetApproverName(approverID) // Create outbound order within transaction:三个库存维度显式取自本公司 scope, // 头表这三列 NOT NULL 无默认值,不接受请求体伪造的归属。 order := &model.OutboundOrder{ OrganizationID: scope.OrganizationID, WarehouseID: scope.WarehouseID, LocationID: scope.LocationID, OutboundDate: time.Now(), Handler: approverName, Operator: approverName, Receiver: app.Username, Status: "completed", Items: make([]model.OutboundItem, len(app.Items)), } for i, appItem := range app.Items { order.Items[i] = model.OutboundItem{ MaterialID: appItem.MaterialID, Quantity: appItem.Quantity, } } // 按物料 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] 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 } } // 创建带 scope 的出库单和明细。 if err := s.outboundRepo.CreateTx(tx, order); err != nil { return err } // 创建带三维度的库存流水,维度与出库单、余额完全一致。 for _, idx := range materialSortedIndexes(itemIDs) { appItem := app.Items[idx] 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.stockRepo.CreateLogTx(tx, stockLog); err != nil { return err } } } // 带组织、ID 和原状态 pending 的条件更新:重复审批只允许一次成功,不产生重复出库。 if err := s.appRepo.ApproveTx(tx, organizationID, id, approverID); err != nil { if errors.Is(err, repository.ErrApplicationStatusConflict) { return ErrApplicationNotPending } return err } if err := tx.Commit(); err != nil { return err } committed = true return nil } func (s *ApplicationService) Reject(organizationID, id int64, reason string) error { app, err := s.appRepo.GetByID(organizationID, id) if err != nil { return ErrApplicationNotFound } if app.Status != model.ApplicationStatusPending { return ErrApplicationNotPending } // 条件更新带公司、ID 和原状态,防止并发覆盖。 if err := s.appRepo.Reject(organizationID, id, reason); err != nil { if errors.Is(err, repository.ErrApplicationStatusConflict) { return ErrApplicationNotPending } return err } return nil } func (s *ApplicationService) Cancel(organizationID, id, userID int64) error { app, err := s.appRepo.GetByID(organizationID, id) if err != nil { return ErrApplicationNotFound } // User can only cancel their own applications if app.UserID != userID { return ErrCannotCancelOthers } if app.Status != model.ApplicationStatusPending { return ErrCannotCancelApplication } // 条件更新带公司、ID 和原状态,防止并发覆盖。 if err := s.appRepo.Cancel(organizationID, id); err != nil { if errors.Is(err, repository.ErrApplicationStatusConflict) { return ErrCannotCancelApplication } return err } return nil } 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) }