material_manage_system/internal/service/application.go

273 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}