73955a139c
- 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
776 lines
24 KiB
Go
776 lines
24 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")
|
||
|
||
// 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
|
||
}
|