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

353 lines
10 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 (
"time"
"github.com/shopspring/decimal"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
)
type InboundService struct {
inboundRepo *repository.InboundRepo
materialSvc *MaterialService
stockRepo *repository.StockRepo
}
func NewInboundService() *InboundService {
return &InboundService{
inboundRepo: repository.NewInboundRepo(),
materialSvc: NewMaterialService(),
stockRepo: repository.NewStockRepo(),
}
}
// resolveWarningEnabled 决定本次写入预警规则时是否启用。调用方已经确认阈值非 nil。
// 未显式传 warning_enabled 时默认启用,保持老客户端"带上阈值即启用"的行为。
func resolveWarningEnabled(enabled *bool) bool {
if enabled == nil {
return true
}
return *enabled
}
func (s *InboundService) Create(organizationID int64, req *model.CreateInboundRequest, operator string) (*model.InboundOrder, error) {
order := &model.InboundOrder{
InboundDate: time.Now(),
Operator: operator,
Handler: req.Handler,
Supplier: req.Supplier,
Remark: req.Remark,
Items: make([]model.InboundItem, len(req.Items)),
}
// 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
}
if item.WarningStock != nil && item.WarningStock.IsNegative() {
return nil, ErrInvalidWarningStock
}
order.Items[i] = model.InboundItem{
MaterialID: item.MaterialID,
Quantity: item.Quantity,
}
}
// 库存维度由服务端按当前公司解析:本公司的唯一启用仓库和库位。缺失或停用时返回
// 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
// Start transaction
tx, err := repository.BeginTx()
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Create inbound order within transaction
err = s.inboundRepo.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]
// 这里必须用赋值而不是 := ,否则 err 会被遮蔽,defer 里的回滚判断看不到它。
var material *model.Material
material, err = s.materialSvc.GetByID(organizationID, item.MaterialID)
if err != nil {
return nil, err
}
item.MaterialCode = material.MaterialCode
item.MaterialName = material.Name
err = s.stockRepo.UpdateStockTx(tx, scope, item.MaterialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil {
return nil, err
}
// 入库明细上带的预警阈值在同一个事务里写入,保证物料的第一个阈值和第一行库存
// 同时落库。阈值为 nil 表示本次不改动已存的值;阈值为 0 是合法配置,含义是
// "库存降到 0 时预警"。
if warningStock := req.Items[i].WarningStock; warningStock != nil {
err = s.stockRepo.UpdateWarningStockTx(tx, scope, item.MaterialID, *warningStock,
resolveWarningEnabled(req.Items[i].WarningEnabled))
if err != nil {
return nil, err
}
}
stockLog := &model.StockLog{
OrganizationID: scope.OrganizationID,
WarehouseID: scope.WarehouseID,
LocationID: scope.LocationID,
MaterialID: item.MaterialID,
Type: "in",
Quantity: item.Quantity,
RelatedOrderType: "inbound",
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
}
func (s *InboundService) GetByID(organizationID, id int64) (*model.InboundOrder, error) {
return s.inboundRepo.GetByID(organizationID, id)
}
func (s *InboundService) List(organizationID int64, search string, limit, offset int) ([]model.InboundOrder, int64, error) {
return s.inboundRepo.List(organizationID, search, limit, offset)
}
func (s *InboundService) GetByMaterialID(organizationID, materialID int64) ([]model.InboundRecord, error) {
return s.inboundRepo.GetByMaterialID(organizationID, materialID)
}
func (s *InboundService) Delete(organizationID, id int64) error {
// Start transaction
tx, err := repository.BeginTx()
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// 在事务中锁定当前公司的原单据;别的公司的单据在这里命中 ErrNoRows(Handler 映射 404),
// 不会去冲销别家公司的库存。
order, err := s.inboundRepo.GetByIDForUpdateTx(tx, organizationID, id)
if err != nil {
return err
}
// 冲销必须在当前公司启用的库存维度上进行。
scope, err := ResolveInventoryScope(organizationID, true)
if err != nil {
return err
}
// Reverse stock for each item within transaction
for _, item := range order.Items {
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, "inbound", id)
if err != nil {
return err
}
// Delete the inbound order (items will be cascade deleted)
err = s.inboundRepo.DeleteTx(tx, organizationID, id)
if err != nil {
return err
}
// Commit transaction
if err = tx.Commit(); err != nil {
return err
}
return nil
}
func (s *InboundService) Update(organizationID, id int64, req *model.UpdateInboundRequest) (*model.InboundOrder, error) {
// Get old order data(带组织条件,别家公司的单据在这里就命中 ErrNoRows)
oldOrder, err := s.inboundRepo.GetByID(organizationID, id)
if err != nil {
return nil, err
}
// 阈值校验放在事务之外,避免为非法输入白开一个事务
for _, item := range req.Items {
active, err := s.materialSvc.IsActive(organizationID, item.MaterialID)
if err != nil {
return nil, err
}
if !active {
return nil, ErrMaterialObsolete
}
if item.WarningStock != nil && item.WarningStock.IsNegative() {
return nil, ErrInvalidWarningStock
}
}
// 库存维度由服务端按当前公司解析;缺失或停用时无法改写库存。
scope, err := ResolveInventoryScope(organizationID, true)
if err != nil {
return nil, err
}
// Start transaction
tx, err := repository.BeginTx()
if err != nil {
return nil, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// 在事务中锁定当前公司的原单据,并以锁下的原明细为准冲销;锁只加在本公司单据上。
oldOrder, err = s.inboundRepo.GetByIDForUpdateTx(tx, organizationID, id)
if err != nil {
return nil, err
}
// Delete old stock logs
err = s.stockRepo.DeleteLogsTx(tx, organizationID, "inbound", id)
if err != nil {
return nil, err
}
// Update order in repository
err = s.inboundRepo.UpdateTx(tx, organizationID, id, req)
if err != nil {
return nil, err
}
// 旧明细(冲销)与新明细(补货)合并后按物料 ID 统一升序处理,逐物料先冲销旧量、
// 再补新量。两个并发的 Update 交换物料时也会以相同全局顺序加锁,不会死锁。
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.GetStockForUpdateTx(tx, scope, materialID)
if err != nil {
return nil, err
}
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.GetStockForUpdateTx(tx, scope, materialID)
if err != nil {
return nil, err
}
err = s.stockRepo.UpdateStockTx(tx, scope, materialID, decimal.NewFromInt(int64(item.Quantity)))
if err != nil {
return nil, err
}
// 阈值随明细一起更新,nil 表示不改动已存的值,避免编辑旧入库单时把刚调好的
// 阈值覆盖回旧数;启用状态同理,nil 表示不改动。
if item.WarningStock != nil {
err = s.stockRepo.UpdateWarningStockTx(tx, scope, materialID, *item.WarningStock,
resolveWarningEnabled(item.WarningEnabled))
if err != nil {
return nil, err
}
}
stockLog := &model.StockLog{
OrganizationID: scope.OrganizationID,
WarehouseID: scope.WarehouseID,
LocationID: scope.LocationID,
MaterialID: materialID,
Type: "in",
Quantity: item.Quantity,
RelatedOrderType: "inbound",
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.inboundRepo.GetByID(organizationID, id)
if err != nil {
return nil, err
}
updatedOrder.Status = "completed"
return updatedOrder, nil
}