material_manage_system/internal/service/outbound.go

359 lines
9.7 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 (
"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
}