Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
This commit is contained in:
@@ -13,6 +13,9 @@ import (
|
||||
|
||||
var ErrInsufficientStock = errors.New("insufficient stock")
|
||||
|
||||
// ErrForbidden 表示当前用户无权执行该操作(handler 据此返回 403)。
|
||||
var ErrForbidden = errors.New("forbidden")
|
||||
|
||||
type StockService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -269,6 +272,224 @@ func partnerLastBalance(tx *gorm.DB, shopID uint64, partnerID *uint64) float64 {
|
||||
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.TotalPrice
|
||||
}
|
||||
|
||||
// 冲减应付(负向调整记录,滚动余额)
|
||||
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.UnitPrice != 0 {
|
||||
up := it.UnitPrice
|
||||
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
|
||||
returnedAmount += it.TotalPrice
|
||||
}
|
||||
|
||||
// 冲减应收(负向调整记录)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user