material_manage_system/internal/service/outbound.go
2026-10-09 12:21:53 +08:00

370 lines
11 KiB
Go
Raw Permalink 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
}
// 单据归属只取服务端解析出的当前公司维度,请求体里伪造的归属一律被覆盖。
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
}