chore: release server-v1.0.74
Deploy Server / release-deploy-server (push) Successful in 2m4s

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
This commit is contained in:
wangjia
2026-06-21 22:17:24 +08:00
parent 2e941fdb5f
commit 20f9e3a410
9 changed files with 499 additions and 0 deletions
+221
View File
@@ -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