f928d5c6a3
- #41 CheckInventoryAvailability Scan 加错误检查,DB 故障不再误报库存不足 - #42 finance ListRecords 和 inventory Logs 补加 pageSize 上限(500) - #43 ReconcileInventory 两条 Scan 加错误检查,DB 故障返回 500 而非误导性空结果 - #44 license Info 改用 CountDevices(licenseID) 统计当前 license 绑定数,修复跨 license 计数偏差 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
335 lines
9.0 KiB
Go
335 lines
9.0 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
|
|
}
|
|
|
|
// CheckInventoryAvailability validates that the warehouse has sufficient stock for each item.
|
|
// Returns an error describing the first shortage encountered.
|
|
func (s *StockService) CheckInventoryAvailability(shopID, warehouseID uint64, items []model.StockOutItem) error {
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
|
|
productIDs := make([]uint64, 0, len(items))
|
|
for _, item := range items {
|
|
productIDs = append(productIDs, item.ProductID)
|
|
}
|
|
|
|
type inventorySum struct {
|
|
ProductID uint64
|
|
Total float64
|
|
}
|
|
var sums []inventorySum
|
|
if err := s.db.Model(&model.Inventory{}).
|
|
Select("product_id, COALESCE(SUM(quantity), 0) AS total").
|
|
Where("shop_id = ? AND warehouse_id = ? AND product_id IN ? AND deleted_at IS NULL",
|
|
shopID, warehouseID, productIDs).
|
|
Group("product_id").Scan(&sums).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
sumMap := make(map[uint64]float64, len(sums))
|
|
for _, s := range sums {
|
|
sumMap[s.ProductID] = s.Total
|
|
}
|
|
|
|
for _, item := range items {
|
|
have := sumMap[item.ProductID]
|
|
if have < item.Quantity {
|
|
var p model.Product
|
|
s.db.Where("id = ?", item.ProductID).First(&p)
|
|
name := p.Name
|
|
if name == "" {
|
|
name = fmt.Sprintf("商品ID %d", item.ProductID)
|
|
}
|
|
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
|
|
}
|
|
}
|
|
return nil
|
|
}
|