Files
jiu/backend/internal/service/stock.go
T
wangjia 51cfe5fc6d fix(backend): 修复库存 TOCTOU 竞态 + handler 白名单更新 + 对账接口
#29 ApproveStockOut:FOR UPDATE 锁定批次后再内存汇总总量,
     消除预检 SUM 与加锁之间的竞态窗口,减少一次 DB 往返

#30 partner/warehouse/user/product_attr/product_option Update 方法:
     - 绑定到独立 req struct,防止请求体覆盖记录 ID
     - 改用 db.Model.Where("shop_id=?").Updates(map) 白名单更新,
       数据库层强制 shop_id 隔离约束

#31 新增 GET /api/v1/admin/reconcile:对比 inventories 当前库存
     与 inventory_logs 流水净量,返回差异行,用于发现不平账异常

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 00:21:48 +08:00

290 lines
7.7 KiB
Go

package service
import (
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/model"
)
var ErrInsufficientStock = errors.New("insufficient stock")
type StockService struct {
db *gorm.DB
}
func NewStockService(db *gorm.DB) *StockService {
return &StockService{db: db}
}
// ApproveStockIn 审核入库单,每个明细行创建一条独立的批次库存记录
func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var order model.StockInOrder
if err := tx.Preload("Items.Product").Preload("Warehouse").Preload("Partner").
Where("id = ? AND shop_id = ?", orderID, shopID).
First(&order).Error; err != nil {
return err
}
if order.Status != "pending" {
return errors.New("order is not in pending status")
}
supplierName := ""
if order.Partner != nil {
supplierName = order.Partner.Name
}
warehouseName := ""
if order.Warehouse != nil {
warehouseName = order.Warehouse.Name
}
now := time.Now()
for _, item := range order.Items {
itemCopy := item
itemID := itemCopy.ID
warehouseID := order.WarehouseID
productID := itemCopy.ProductID
// 计算入库前库存总量(用于流水记录)
var qtyBefore float64
tx.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL",
shopID, warehouseID, productID).
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
var unitPricePtr *float64
if itemCopy.UnitPrice != 0 {
unitPricePtr = &itemCopy.UnitPrice
}
productCode := ""
productName := ""
series := ""
spec := ""
unit := ""
if itemCopy.Product != nil {
productCode = itemCopy.Product.Code
productName = itemCopy.Product.Name
series = itemCopy.Product.Series
spec = itemCopy.Product.Spec
unit = itemCopy.Product.Unit
}
inv := model.Inventory{
ShopID: shopID,
WarehouseID: &warehouseID,
ProductID: &productID,
StockInItemID: &itemID,
Quantity: itemCopy.Quantity,
ProductCode: productCode,
ProductName: productName,
Series: series,
Spec: spec,
Unit: unit,
WarehouseName: warehouseName,
UnitPrice: unitPricePtr,
ProductionDate: itemCopy.ProductionDate,
BatchNo: itemCopy.BatchNo,
SupplierName: supplierName,
}
if err := tx.Create(&inv).Error; err != nil {
return err
}
log := model.InventoryLog{
ShopID: shopID,
WarehouseID: warehouseID,
ProductID: productID,
Direction: "in",
Quantity: itemCopy.Quantity,
QtyBefore: qtyBefore,
QtyAfter: qtyBefore + itemCopy.Quantity,
RefType: "stock_in",
RefID: orderID,
OperatorID: &reviewerID,
}
if err := tx.Create(&log).Error; err != nil {
return err
}
}
// 自动创建应付账款财务记录
{
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount
orderID := order.ID
rec := model.FinanceRecord{
ShopID: shopID,
PartnerID: order.PartnerID,
Type: "payable",
Amount: order.TotalAmount,
Balance: bal,
Status: "open",
RefType: "stock_in",
RefID: &orderID,
OperatorID: reviewerID,
RecordDate: order.OrderDate.Time,
}
if err := tx.Create(&rec).Error; err != nil {
return err
}
}
return tx.Model(&order).Updates(map[string]interface{}{
"status": "approved",
"reviewer_id": reviewerID,
"reviewed_at": now,
}).Error
})
}
// ApproveStockOut 审核出库单,FIFO 扣减批次库存
func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var order model.StockOutOrder
if err := tx.Preload("Items").
Where("id = ? AND shop_id = ?", orderID, shopID).
First(&order).Error; err != nil {
return err
}
if order.Status != "pending" {
return errors.New("order is not in pending status")
}
now := time.Now()
warehouseID := order.WarehouseID
for _, item := range order.Items {
itemCopy := item
productID := itemCopy.ProductID
needed := itemCopy.Quantity
// 1. FOR UPDATE 锁定批次后再汇总,消除 TOCTOU 窗口
var batches []model.Inventory
tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
shopID, warehouseID, productID).
Order("created_at ASC").Find(&batches)
var totalQty float64
for _, b := range batches {
totalQty += b.Quantity
}
if totalQty < needed {
return fmt.Errorf("%w: product_id=%d, available=%.3f, required=%.3f",
ErrInsufficientStock, productID, totalQty, needed)
}
qtyBefore := totalQty
// 2. FIFO 扣减
remaining := needed
for i := range batches {
if remaining <= 0 {
break
}
b := &batches[i]
if b.Quantity <= remaining {
remaining -= b.Quantity
tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
} else {
tx.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
remaining = 0
}
}
// 3. 写流水
log := model.InventoryLog{
ShopID: shopID,
WarehouseID: warehouseID,
ProductID: productID,
Direction: "out",
Quantity: needed,
QtyBefore: qtyBefore,
QtyAfter: qtyBefore - needed,
RefType: "stock_out",
RefID: orderID,
OperatorID: &reviewerID,
}
if err := tx.Create(&log).Error; err != nil {
return err
}
}
// 自动创建应收账款财务记录
{
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount
oid := order.ID
rec := model.FinanceRecord{
ShopID: shopID,
PartnerID: order.PartnerID,
Type: "receivable",
Amount: order.TotalAmount,
Balance: bal,
Status: "open",
RefType: "stock_out",
RefID: &oid,
OperatorID: reviewerID,
RecordDate: order.OrderDate.Time,
}
if err := tx.Create(&rec).Error; err != nil {
return err
}
}
return tx.Model(&order).Updates(map[string]interface{}{
"status": "approved",
"reviewer_id": reviewerID,
"reviewed_at": now,
}).Error
})
}
// partnerLastBalance 查询该往来单位最后一条财务记录的余额(用于计算滚动余额)
func partnerLastBalance(tx *gorm.DB, shopID uint64, partnerID *uint64) float64 {
var last model.FinanceRecord
q := tx.Where("shop_id = ? AND deleted_at IS NULL", shopID)
if partnerID != nil {
q = q.Where("partner_id = ?", *partnerID)
} else {
q = q.Where("partner_id IS NULL")
}
q.Order("id DESC").First(&last)
return last.Balance
}
// GenerateOrderNo 生成单号(事务安全,FOR UPDATE 防止并发重复单号)
func (s *StockService) GenerateOrderNo(shopID uint64, orderType string) (string, error) {
var no string
err := s.db.Transaction(func(tx *gorm.DB) error {
var rule model.NumberRule
result := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND type = ?", shopID, orderType).First(&rule)
if result.Error != nil {
// 初始化规则(使用中文惯用前缀)
prefixMap := map[string]string{
"stock_in": "RK", "stock_out": "CK",
"inventory_check": "PD", "product": "SP",
}
prefix := prefixMap[orderType]
if prefix == "" {
prefix = strings.ToUpper(orderType[:2])
}
rule = model.NumberRule{ShopID: shopID, Type: orderType, Prefix: prefix, DateFormat: "YYYYMMDD", CurrentNo: 0}
tx.Create(&rule)
}
rule.CurrentNo++
tx.Model(&rule).Update("current_no", rule.CurrentNo)
dateStr := time.Now().Format("20060102")
no = fmt.Sprintf("%s%s%06d", rule.Prefix, dateStr, rule.CurrentNo)
return nil
})
return no, err
}