370 lines
11 KiB
Go
370 lines
11 KiB
Go
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
|
||
}
|
||
// 单据归属只取服务端解析出的当前公司维度,请求体里伪造的归属一律被覆盖。
|
||
order.OrganizationID = scope.OrganizationID
|
||
order.WarehouseID = scope.WarehouseID
|
||
order.LocationID = scope.LocationID
|
||
|
||
// 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(organizationID, id int64) (*model.OutboundOrder, error) {
|
||
return s.outboundRepo.GetByID(organizationID, id)
|
||
}
|
||
|
||
func (s *OutboundService) List(organizationID int64, search string, limit, offset int) ([]model.OutboundOrder, int64, error) {
|
||
return s.outboundRepo.List(organizationID, search, limit, offset)
|
||
}
|
||
|
||
func (s *OutboundService) GetByMaterialID(organizationID, materialID int64) ([]model.OutboundRecord, error) {
|
||
return s.outboundRepo.GetByMaterialID(organizationID, materialID)
|
||
}
|
||
|
||
func (s *OutboundService) Delete(organizationID, id int64) error {
|
||
// Start transaction
|
||
tx, err := repository.BeginTx()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// 在事务中锁定当前公司的原单据;别的公司的单据在这里命中 ErrNoRows(Handler 映射 404),
|
||
// 不会去冲销别家公司的库存。
|
||
order, err := s.outboundRepo.GetByIDForUpdateTx(tx, organizationID, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 冲销必须在当前公司启用的库存维度上进行。
|
||
scope, err := ResolveInventoryScope(organizationID, true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 按物料 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, organizationID, "outbound", id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Delete the outbound order (items will be cascade deleted)
|
||
err = s.outboundRepo.DeleteTx(tx, organizationID, 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(带组织条件,别家公司的单据在这里就命中 ErrNoRows)
|
||
oldOrder, err := s.outboundRepo.GetByID(organizationID, 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()
|
||
|
||
// 在事务中锁定当前公司的原单据,并以锁下的原明细为准冲销;锁只加在本公司单据上。
|
||
oldOrder, err = s.outboundRepo.GetByIDForUpdateTx(tx, organizationID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Delete old stock logs
|
||
err = s.stockRepo.DeleteLogsTx(tx, organizationID, "outbound", id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Update order in repository
|
||
// 客户端不能指定操作人;编辑也由当前登录用户接管操作人和兼容字段 handler。
|
||
req.Handler = operator
|
||
req.Operator = operator
|
||
err = s.outboundRepo.UpdateTx(tx, organizationID, 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(organizationID, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updatedOrder.Status = "completed"
|
||
|
||
return updatedOrder, nil
|
||
}
|