Files
jiu/backend/internal/service/stock.go
T
wangjia 73955a139c feat(backend): 库存三态落库(stock/on_sale/sold)——挂售可控 + 卖光留痕
- inventories 加 status 枚举列(默认 stock,AutoMigrate 存量即全部「库存」)+ 索引
- 出库审核扣光置 sold 不再软删(留痕台账);退单恢复行置回 stock;盘亏仍软删
- 新接口 PUT /inventory/:id/status(仅收 stock/on_sale,已售行拒改,租户校验)
- List 默认排除已售(显式 status=sold 才显示)+ status 多值筛选
- Summary:SKU/货值/数量排除已售,新增 sold_count
- 公开店铺页/API 只列 on_sale——酒单从全量裸奔改为人工精选
- 测试:卖光置售/部分不改/退单回库/接口边界/跨租户/汇总口径 8 用例
- 设计方案 docs/design/inventory-sale-status.html + db-schema/CLAUDE.md 同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 13:35:03 +08:00

776 lines
24 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 (
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/model"
)
var ErrInsufficientStock = errors.New("insufficient stock")
// ErrForbidden 表示当前用户无权执行该操作(handler 据此返回 403)。
var ErrForbidden = errors.New("forbidden")
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
if err := 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).Error; err != nil {
return err
}
var unitPricePtr *float64
if itemCopy.CostPrice != 0 {
unitPricePtr = &itemCopy.CostPrice
}
// 优先使用明细自带快照列(历史导入单的真实商品信息在此),
// Product 关联仅作兜底(普通 UI 录入单的明细快照为空)。
productCode := itemCopy.ProductCode
productName := itemCopy.ProductName
series := itemCopy.Series
spec := itemCopy.Spec
unit := ""
if itemCopy.Product != nil {
if productCode == "" {
productCode = itemCopy.Product.Code
}
if productName == "" {
productName = itemCopy.Product.Name
}
if series == "" {
series = itemCopy.Product.Series
}
if spec == "" {
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.CostTotal
orderID := order.ID
rec := model.FinanceRecord{
ShopID: shopID,
PartnerID: order.PartnerID,
Type: "payable",
Amount: order.CostTotal,
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
})
}
// CostConfirmItem 是「确认进价」的单行入参:把某入库明细的进价补成真实值。
type CostConfirmItem struct {
ItemID uint64 `json:"item_id"`
UnitPrice float64 `json:"unit_price"`
}
// SaleConfirmItem 出库「确认售价」的一条明细(先出后定价)。
type SaleConfirmItem struct {
ItemID uint64 `json:"item_id"`
SalePrice float64 `json:"sale_price"`
}
// ConfirmStockInCost 「确认进价」:调货等场景以 0 价(暂估)入库并已审核/已出库后,
// 价格确定时补填真实进价。不反审核、不撤销任何已发生的动作,而是在一个事务内
// 前向写补偿:
// 1. 入库明细 CostPrice / CostAmount 改为真实值
// 2. 该明细对应的剩余库存批次 Inventory.UnitPrice 由 NULL→真实值
// 3. 该 product 已出库行 StockOutItem.CostPrice/CostAmount(成本快照)回填真实值
// product↔入库明细 1:1,按 product_id 精确命中;不动 SalePrice/应收)
// 4. 入库单 CostTotal 按差额重算
// 5. 对供应商应付按差额补一条调整流水(滚动余额)
//
// 仅 approved 单可确认(draft 直接编辑即可);权限:管理员/超管。
// 以「旧价→新价」差额计算补偿,支持多次修正(再次确认按新旧差额补)。
func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role string, items []CostConfirmItem) error {
if role != "admin" && role != "superadmin" {
return ErrForbidden
}
return s.db.Transaction(func(tx *gorm.DB) error {
var order model.StockInOrder
if err := tx.Preload("Items").Where("id = ? AND shop_id = ?", orderID, shopID).
First(&order).Error; err != nil {
return err
}
if order.Status != "approved" {
return errors.New("只有已审核单据可确认进价")
}
want := make(map[uint64]float64, len(items))
for _, it := range items {
want[it.ItemID] = it.UnitPrice
}
now := time.Now()
var totalDiff float64
var changedProducts []uint64 // 成本被改的商品:其已出库单的利润需联动重算
for i := range order.Items {
it := &order.Items[i]
newPrice, ok := want[it.ID]
if !ok || newPrice == it.CostPrice {
continue
}
oldPrice := it.CostPrice
diff := (newPrice - oldPrice) * it.Quantity
totalDiff += diff
// 1. 入库明细
if err := tx.Model(it).Updates(map[string]interface{}{
"cost_price": newPrice,
"cost_amount": newPrice * it.Quantity,
}).Error; err != nil {
return err
}
// 2. 该明细的剩余库存批次(已全部出库则无行,跳过)
if err := tx.Model(&model.Inventory{}).
Where("shop_id = ? AND stock_in_item_id = ? AND deleted_at IS NULL", shopID, it.ID).
Update("unit_price", newPrice).Error; err != nil {
return err
}
// 3. 已出库行成本快照回填(按 product_id 精确命中;product_id=0 的历史导入单不传播)
if it.ProductID != 0 {
if err := tx.Model(&model.StockOutItem{}).
Where("shop_id = ? AND product_id = ?", shopID, it.ProductID).
Updates(map[string]interface{}{
"cost_price": newPrice,
"cost_amount": gorm.Expr("? * quantity", newPrice),
}).Error; err != nil {
return err
}
}
// 同步 product 参考进价
if it.ProductID != 0 {
tx.Model(&model.Product{}).Where("id = ? AND shop_id = ?", it.ProductID, shopID).
Update("purchase_price", newPrice)
}
if it.ProductID != 0 {
changedProducts = append(changedProducts, it.ProductID)
}
}
// 3.5 成本快照变了 → 受影响出库单的总利润联动重算
if len(changedProducts) > 0 {
var outOrderIDs []uint64
if err := tx.Model(&model.StockOutItem{}).
Where("shop_id = ? AND product_id IN ?", shopID, changedProducts).
Distinct().Pluck("order_id", &outOrderIDs).Error; err != nil {
return err
}
if err := recalcStockOutProfit(tx, shopID, outOrderIDs); err != nil {
return err
}
}
if totalDiff == 0 {
return nil
}
// 4. 入库单应付合计按差额重算
if err := tx.Model(&order).
Update("cost_total", gorm.Expr("cost_total + ?", totalDiff)).Error; err != nil {
return err
}
// 5. 应付差额调整流水(滚动余额)
bal := partnerLastBalance(tx, shopID, order.PartnerID) + totalDiff
oid := order.ID
return tx.Create(&model.FinanceRecord{
ShopID: shopID, PartnerID: order.PartnerID, Type: "payable",
Amount: totalDiff, Balance: bal, Status: "open",
RefType: "stock_in_cost_adjust", RefID: &oid, OperatorID: userID, RecordDate: now,
}).Error
})
}
// recalcStockOutProfit 按行利润口径整单重算总利润:
// profit_total = Σ(sale_price>0 ? (sale_price-cost_price)×quantity : 0)。
// 确认售价 / 确认进价(成本回填)后调用,保证利润与两侧价格始终一致。
func recalcStockOutProfit(tx *gorm.DB, shopID uint64, orderIDs []uint64) error {
if len(orderIDs) == 0 {
return nil
}
return tx.Exec(`UPDATE stock_out_orders SET profit_total = (
SELECT COALESCE(SUM(CASE WHEN i.sale_price > 0
THEN (i.sale_price - i.cost_price) * i.quantity ELSE 0 END), 0)
FROM stock_out_items i WHERE i.order_id = stock_out_orders.id
) WHERE shop_id = ? AND id IN ?`, shopID, orderIDs).Error
}
// ConfirmStockOutSale 出库「先出后定价」:对 sale_price<=0 的明细写真实售价,
// 重算应收(sale_total=Σ售价小计)与总利润,并补应收差额流水。仅 admin/superadmin、已审核单可确认。
func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role string, items []SaleConfirmItem) error {
if role != "admin" && role != "superadmin" {
return ErrForbidden
}
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 != "approved" {
return errors.New("只有已审核单据可确认售价")
}
want := make(map[uint64]float64, len(items))
for _, it := range items {
want[it.ItemID] = it.SalePrice
}
now := time.Now()
var totalDiff float64
for i := range order.Items {
it := &order.Items[i]
newPrice, ok := want[it.ID]
if !ok || newPrice == it.SalePrice {
continue
}
diff := (newPrice - it.SalePrice) * it.Quantity
totalDiff += diff
// 售价与售价小计同步改,避免「单价×数量 ≠ 金额」的口径漂移
if err := tx.Model(it).Updates(map[string]interface{}{
"sale_price": newPrice,
"sale_amount": newPrice * it.Quantity,
}).Error; err != nil {
return err
}
}
if totalDiff == 0 {
return nil
}
// 应收合计按差额重算;总利润整单重算
if err := tx.Model(&order).
Update("sale_total", gorm.Expr("sale_total + ?", totalDiff)).Error; err != nil {
return err
}
if err := recalcStockOutProfit(tx, shopID, []uint64{order.ID}); err != nil {
return err
}
// 应收差额调整流水(滚动余额,与出库审核建应收同口径 last+amount
bal := partnerLastBalance(tx, shopID, order.PartnerID) + totalDiff
oid := order.ID
return tx.Create(&model.FinanceRecord{
ShopID: shopID, PartnerID: order.PartnerID, Type: "receivable",
Amount: totalDiff, Balance: bal, Status: "open",
RefType: "stock_out_sale_adjust", RefID: &oid, OperatorID: userID, RecordDate: 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
// 卖光的批次置「已售」留痕(2026-07-07 三态改造:不再软删)
tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "status": "sold"})
} 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.SaleTotal
oid := order.ID
rec := model.FinanceRecord{
ShopID: shopID,
PartnerID: order.PartnerID,
Type: "receivable",
Amount: order.SaleTotal,
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
}
// ReturnStockIn 入库单退单:把选中明细退掉,对应库存从库存中删除,并冲减应付。
// 仅 approved 单可退;权限:管理员/超管。已被出库消耗的明细禁止退(库存不足)。
func (s *StockService) ReturnStockIn(shopID, orderID, userID uint64, role string, itemIDs []uint64) error {
if role != "admin" && role != "superadmin" {
return ErrForbidden
}
return s.db.Transaction(func(tx *gorm.DB) error {
var order model.StockInOrder
if err := tx.Preload("Items").Where("id = ? AND shop_id = ?", orderID, shopID).
First(&order).Error; err != nil {
return err
}
if order.Status != "approved" {
return errors.New("只有已审核单据可退单")
}
want := idSet(itemIDs)
now := time.Now()
var returnedAmount float64
for i := range order.Items {
it := &order.Items[i]
if !want[it.ID] || it.ReturnedQuantity >= it.Quantity {
continue
}
// 找该明细审核时建的库存行(FOR UPDATE)
var inv model.Inventory
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND stock_in_item_id = ? AND deleted_at IS NULL", shopID, it.ID).
First(&inv).Error; err != nil {
return fmt.Errorf("明细「%s」库存已出库,无法退单", itemDesc(it.ProductName, it.ProductCode))
}
// 已被部分出库(库存数量 < 入库数量)→ 禁止退
if inv.Quantity < it.Quantity {
return fmt.Errorf("明细「%s」库存已部分出库,无法退单", itemDesc(it.ProductName, it.ProductCode))
}
wid, pid := uint64(0), uint64(0)
if inv.WarehouseID != nil {
wid = *inv.WarehouseID
}
if inv.ProductID != nil {
pid = *inv.ProductID
}
// 删库存行 + 写反向流水
if err := tx.Model(&inv).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now}).Error; err != nil {
return err
}
if err := tx.Create(&model.InventoryLog{
ShopID: shopID, WarehouseID: wid, ProductID: pid,
Direction: "out", Quantity: it.Quantity, QtyBefore: inv.Quantity, QtyAfter: 0,
RefType: "stock_in_return", RefID: orderID, OperatorID: &userID,
}).Error; err != nil {
return err
}
if err := tx.Model(it).Update("returned_quantity", it.Quantity).Error; err != nil {
return err
}
it.ReturnedQuantity = it.Quantity
returnedAmount += it.CostAmount
}
// 冲减应付(负向调整记录,滚动余额)
if returnedAmount > 0 {
bal := partnerLastBalance(tx, shopID, order.PartnerID) - returnedAmount
oid := order.ID
if err := tx.Create(&model.FinanceRecord{
ShopID: shopID, PartnerID: order.PartnerID, Type: "payable",
Amount: -returnedAmount, Balance: bal, Status: "closed",
RefType: "stock_in_return", RefID: &oid, OperatorID: userID, RecordDate: now,
}).Error; err != nil {
return err
}
}
return tx.Model(&order).Update("return_state", returnStateOf(order.Items)).Error
})
}
// ReturnStockOut 出库单退单:把选中明细退掉,数量加回库存(新建库存行),并冲减应收。
// 仅 approved 单可退;权限:管理员/超管 或 本人(operator)。
func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role string, itemIDs []uint64) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var order model.StockOutOrder
if err := tx.Preload("Items.Product").Where("id = ? AND shop_id = ?", orderID, shopID).
First(&order).Error; err != nil {
return err
}
if role != "admin" && role != "superadmin" && order.OperatorID != userID {
return ErrForbidden
}
if order.Status != "approved" {
return errors.New("只有已审核单据可退单")
}
want := idSet(itemIDs)
now := time.Now()
warehouseID := order.WarehouseID
var returnedAmount float64
for i := range order.Items {
it := &order.Items[i]
if !want[it.ID] || it.ReturnedQuantity >= it.Quantity {
continue
}
productID := it.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 it.CostPrice != 0 {
up := it.CostPrice
unitPricePtr = &up
}
unit := ""
if it.Product != nil {
unit = it.Product.Unit
}
// 加回库存:新建一条库存行
inv := model.Inventory{
ShopID: shopID, WarehouseID: &warehouseID, ProductID: &productID,
Quantity: it.Quantity,
ProductCode: it.ProductCode,
ProductName: it.ProductName,
Series: it.Series,
Spec: it.Spec,
Unit: unit,
UnitPrice: unitPricePtr,
ProductionDate: it.ProductionDate,
BatchNo: it.BatchNo,
}
if err := tx.Create(&inv).Error; err != nil {
return err
}
if err := tx.Create(&model.InventoryLog{
ShopID: shopID, WarehouseID: warehouseID, ProductID: productID,
Direction: "in", Quantity: it.Quantity, QtyBefore: qtyBefore, QtyAfter: qtyBefore + it.Quantity,
RefType: "stock_out_return", RefID: orderID, OperatorID: &userID,
}).Error; err != nil {
return err
}
if err := tx.Model(it).Update("returned_quantity", it.Quantity).Error; err != nil {
return err
}
it.ReturnedQuantity = it.Quantity
// 冲应收按售价口径(与审核建应收 SaleTotal=Σ售价×数量 一致)
returnedAmount += it.Quantity * it.SalePrice
}
// 冲减应收(负向调整记录)
if returnedAmount > 0 {
bal := partnerLastBalance(tx, shopID, order.PartnerID) - returnedAmount
oid := order.ID
if err := tx.Create(&model.FinanceRecord{
ShopID: shopID, PartnerID: order.PartnerID, Type: "receivable",
Amount: -returnedAmount, Balance: bal, Status: "closed",
RefType: "stock_out_return", RefID: &oid, OperatorID: userID, RecordDate: now,
}).Error; err != nil {
return err
}
}
return tx.Model(&order).Update("return_state", returnStateOfOut(order.Items)).Error
})
}
func idSet(ids []uint64) map[uint64]bool {
m := make(map[uint64]bool, len(ids))
for _, id := range ids {
m[id] = true
}
return m
}
func itemDesc(name, code string) string {
if name == "" {
return code
}
if code == "" {
return name
}
return name + " " + code
}
// returnStateOf 据明细已退情况推导入库单退单状态。
func returnStateOf(items []model.StockInItem) string {
total, returned := 0, 0
for _, it := range items {
total++
if it.ReturnedQuantity >= it.Quantity {
returned++
}
}
switch {
case returned == 0:
return "none"
case returned >= total:
return "full"
default:
return "partial"
}
}
func returnStateOfOut(items []model.StockOutItem) string {
total, returned := 0, 0
for _, it := range items {
total++
if it.ReturnedQuantity >= it.Quantity {
returned++
}
}
switch {
case returned == 0:
return "none"
case returned >= total:
return "full"
default:
return "partial"
}
}
// 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) //nolint:errcheck — best-effort name lookup for error message
name := p.Name
if name == "" {
name = fmt.Sprintf("商品ID %d", item.ProductID)
}
return fmt.Errorf("库存不足:%s 当前库存 %.0f,需要 %.0f", name, have, item.Quantity)
}
}
return nil
}