273 lines
8.3 KiB
Go
273 lines
8.3 KiB
Go
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)
|
||
}
|