Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
This commit is contained in:
@@ -5,6 +5,14 @@
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.74] - 2026-06-21
|
||||
|
||||
### 新功能
|
||||
- 已审核的入库单/出库单支持「退单」:可整单退或按明细行退货。出库单退单的商品数量加回库存、入库单退单对应库存从库存删除,并自动冲减对应的应付/应收账款。权限:入库退单限管理员/超管;出库退单管理员/超管或本人。已被出库消耗的入库明细禁止退单。
|
||||
|
||||
### 修复
|
||||
- 库存导入改为按商品编号匹配商品、不再按名称合并,避免再次导入产生重复编号
|
||||
|
||||
## [1.0.73] - 2026-06-21
|
||||
|
||||
### 改进
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -283,3 +284,33 @@ func (h *StockInHandler) Withdraw(c *gin.Context) {
|
||||
Update("status", "draft")
|
||||
c.JSON(http.StatusOK, gin.H{"message": "withdrawn"})
|
||||
}
|
||||
|
||||
// Return POST /api/v1/stock-in/orders/:id/return
|
||||
// 入库退单:退掉指定明细,对应库存从库存删除、冲减应付。仅 approved 单、管理员/超管。
|
||||
func (h *StockInHandler) Return(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
userID := middleware.GetUserID(c)
|
||||
role := middleware.GetRole(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
var req struct {
|
||||
ItemIDs []uint64 `json:"item_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.ItemIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未选择退单明细"})
|
||||
return
|
||||
}
|
||||
if err := h.stockSvc.ReturnStockIn(shopID, id, userID, role, req.ItemIDs); err != nil {
|
||||
if errors.Is(err, service.ErrForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权退单"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "returned"})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -268,3 +269,33 @@ func (h *StockOutHandler) Withdraw(c *gin.Context) {
|
||||
Update("status", "draft")
|
||||
c.JSON(http.StatusOK, gin.H{"message": "withdrawn"})
|
||||
}
|
||||
|
||||
// Return POST /api/v1/stock-out/orders/:id/return
|
||||
// 出库退单:退掉指定明细,数量加回库存、冲减应收。仅 approved 单;管理员/超管 或 本人。
|
||||
func (h *StockOutHandler) Return(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
userID := middleware.GetUserID(c)
|
||||
role := middleware.GetRole(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
var req struct {
|
||||
ItemIDs []uint64 `json:"item_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.ItemIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未选择退单明细"})
|
||||
return
|
||||
}
|
||||
if err := h.stockSvc.ReturnStockOut(shopID, id, userID, role, req.ItemIDs); err != nil {
|
||||
if errors.Is(err, service.ErrForbidden) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权退单"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "returned"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
func TestStockReturn_StockOut_OwnAllowed(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "RET_SO")
|
||||
op := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
|
||||
prod := testutil.CreateTestProduct(db, shop.ID, "茅台")
|
||||
opToken := getAuthToken(op.ID, shop.ID, "operator")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 100})
|
||||
|
||||
// 操作员建出库单(本人) → 提交 → 审核
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", opToken, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 15.0, "unit_price": 20.0}},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
orderID := extractID(w)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), opToken, nil)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), opToken, nil)
|
||||
|
||||
var item model.StockOutItem
|
||||
require.NoError(t, db.Where("order_id = ?", orderID).First(&item).Error)
|
||||
|
||||
// 库存已扣到 85
|
||||
var sum float64
|
||||
db.Model(&model.Inventory{}).Where("shop_id = ? AND product_id = ? AND deleted_at IS NULL", shop.ID, prod.ID).
|
||||
Select("COALESCE(SUM(quantity),0)").Scan(&sum)
|
||||
require.Equal(t, float64(85), sum)
|
||||
|
||||
// 本人退单 → 200
|
||||
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-out/orders/%d/return", orderID), opToken,
|
||||
map[string]interface{}{"item_ids": []uint64{item.ID}})
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// 库存加回到 100
|
||||
db.Model(&model.Inventory{}).Where("shop_id = ? AND product_id = ? AND deleted_at IS NULL", shop.ID, prod.ID).
|
||||
Select("COALESCE(SUM(quantity),0)").Scan(&sum)
|
||||
assert.Equal(t, float64(100), sum)
|
||||
|
||||
// 加回是「新建一条库存行」(数量=退单数量 15)
|
||||
var addBack int64
|
||||
db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND product_id = ? AND quantity = 15 AND deleted_at IS NULL", shop.ID, prod.ID).
|
||||
Count(&addBack)
|
||||
assert.Equal(t, int64(1), addBack, "应新建一条数量=15 的加回库存行")
|
||||
|
||||
// 库存流水:一条 in / stock_out_return,85→100
|
||||
var lg model.InventoryLog
|
||||
require.NoError(t, db.Where("shop_id = ? AND ref_type = 'stock_out_return' AND direction = 'in'", shop.ID).
|
||||
First(&lg).Error)
|
||||
assert.Equal(t, float64(15), lg.Quantity)
|
||||
assert.Equal(t, float64(85), lg.QtyBefore)
|
||||
assert.Equal(t, float64(100), lg.QtyAfter)
|
||||
|
||||
// 明细标记已退 + 单据 return_state=full
|
||||
db.First(&item, item.ID)
|
||||
assert.Equal(t, item.Quantity, item.ReturnedQuantity)
|
||||
var order model.StockOutOrder
|
||||
db.First(&order, orderID)
|
||||
assert.Equal(t, "full", order.ReturnState)
|
||||
|
||||
// 应收冲减:存在一条负向 receivable
|
||||
var neg int64
|
||||
db.Model(&model.FinanceRecord{}).
|
||||
Where("shop_id = ? AND type = 'receivable' AND amount < 0 AND ref_type = 'stock_out_return'", shop.ID).
|
||||
Count(&neg)
|
||||
assert.Equal(t, int64(1), neg)
|
||||
}
|
||||
|
||||
func TestStockReturn_StockIn_AdminOnly_AndRemovesInventory(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "RET_SI")
|
||||
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
op := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
|
||||
prod := testutil.CreateTestProduct(db, shop.ID, "五粮液")
|
||||
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
|
||||
opToken := getAuthToken(op.ID, shop.ID, "operator")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
// 入库单 → 审核 → 建库存
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 10.0, "unit_price": 50.0}},
|
||||
})
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
orderID := extractID(w)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), adminToken, nil)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", orderID), adminToken, nil)
|
||||
|
||||
var item model.StockInItem
|
||||
require.NoError(t, db.Where("order_id = ?", orderID).First(&item).Error)
|
||||
pid := item.ProductID // 序列号模型:入库为每行新建独立 product
|
||||
|
||||
var sum float64
|
||||
db.Model(&model.Inventory{}).Where("shop_id = ? AND product_id = ? AND deleted_at IS NULL", shop.ID, pid).
|
||||
Select("COALESCE(SUM(quantity),0)").Scan(&sum)
|
||||
require.Equal(t, float64(10), sum)
|
||||
|
||||
// 操作员退入库单 → 403
|
||||
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/return", orderID), opToken,
|
||||
map[string]interface{}{"item_ids": []uint64{item.ID}})
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
|
||||
// 管理员退入库单 → 200,库存被删
|
||||
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/return", orderID), adminToken,
|
||||
map[string]interface{}{"item_ids": []uint64{item.ID}})
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
db.Model(&model.Inventory{}).Where("shop_id = ? AND product_id = ? AND deleted_at IS NULL", shop.ID, pid).
|
||||
Select("COALESCE(SUM(quantity),0)").Scan(&sum)
|
||||
assert.Equal(t, float64(0), sum)
|
||||
|
||||
// 入库建的那条库存行已被软删(active 中查不到)
|
||||
var active int64
|
||||
db.Model(&model.Inventory{}).
|
||||
Where("shop_id = ? AND stock_in_item_id = ? AND deleted_at IS NULL", shop.ID, item.ID).
|
||||
Count(&active)
|
||||
assert.Equal(t, int64(0), active, "入库退单后对应库存行应被删除")
|
||||
|
||||
// 库存流水:一条 out / stock_in_return,数量=10
|
||||
var lg model.InventoryLog
|
||||
require.NoError(t, db.Where("shop_id = ? AND ref_type = 'stock_in_return' AND direction = 'out'", shop.ID).
|
||||
First(&lg).Error)
|
||||
assert.Equal(t, float64(10), lg.Quantity)
|
||||
|
||||
db.First(&item, item.ID)
|
||||
assert.Equal(t, item.Quantity, item.ReturnedQuantity)
|
||||
var order model.StockInOrder
|
||||
db.First(&order, orderID)
|
||||
assert.Equal(t, "full", order.ReturnState)
|
||||
|
||||
// 应付冲减
|
||||
var neg int64
|
||||
db.Model(&model.FinanceRecord{}).
|
||||
Where("shop_id = ? AND type = 'payable' AND amount < 0 AND ref_type = 'stock_in_return'", shop.ID).
|
||||
Count(&neg)
|
||||
assert.Equal(t, int64(1), neg)
|
||||
}
|
||||
|
||||
func TestStockReturn_StockIn_BlockedWhenSold(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "RET_BLK")
|
||||
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
|
||||
prod := testutil.CreateTestProduct(db, shop.ID, "汾酒")
|
||||
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
// 入库 10 → 审核
|
||||
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 10.0, "unit_price": 50.0}},
|
||||
})
|
||||
inID := extractID(w)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", inID), adminToken, nil)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", inID), adminToken, nil)
|
||||
var inItem model.StockInItem
|
||||
db.Where("order_id = ?", inID).First(&inItem)
|
||||
|
||||
// 出库 4(消耗入库新建的那个 product)→ 审核
|
||||
w = makeRequest(r, "POST", "/api/v1/stock-out/orders", adminToken, map[string]interface{}{
|
||||
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
|
||||
"items": []map[string]interface{}{{"product_id": inItem.ProductID, "quantity": 4.0, "unit_price": 80.0}},
|
||||
})
|
||||
outID := extractID(w)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", outID), adminToken, nil)
|
||||
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", outID), adminToken, nil)
|
||||
|
||||
// 退入库单 → 400(库存已部分出库)
|
||||
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/return", inID), adminToken,
|
||||
map[string]interface{}{"item_ids": []uint64{inItem.ID}})
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
@@ -64,6 +64,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
||||
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
|
||||
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
|
||||
stockIn.PUT("/orders/:id/withdraw", stockInH.Withdraw)
|
||||
stockIn.POST("/orders/:id/return", stockInH.Return)
|
||||
|
||||
// 出库路由
|
||||
stockOut := api.Group("/stock-out")
|
||||
@@ -74,6 +75,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
||||
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
|
||||
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
|
||||
stockOut.PUT("/orders/:id/withdraw", stockOutH.Withdraw)
|
||||
stockOut.POST("/orders/:id/return", stockOutH.Return)
|
||||
|
||||
// 库存路由
|
||||
inv := api.Group("/inventory")
|
||||
|
||||
@@ -17,6 +17,8 @@ type StockInOrder struct {
|
||||
OrderDate Date `gorm:"type:date" json:"order_date"`
|
||||
TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||
// 退单状态:none=无 / partial=部分退单 / full=已全退(仅 approved 单可退)
|
||||
ReturnState string `gorm:"size:20;default:'none'" json:"return_state"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
|
||||
@@ -41,6 +43,8 @@ type StockInItem struct {
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
||||
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
||||
// 已退数量:0=未退,=Quantity 表示整行已退单(整行退,不做部分数量)
|
||||
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
@@ -65,6 +69,8 @@ type StockOutOrder struct {
|
||||
OrderDate Date `gorm:"type:date" json:"order_date"`
|
||||
TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||
// 退单状态:none / partial / full
|
||||
ReturnState string `gorm:"size:20;default:'none'" json:"return_state"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
Remark string `gorm:"size:500" json:"remark"`
|
||||
|
||||
@@ -89,6 +95,8 @@ type StockOutItem struct {
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"`
|
||||
UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"`
|
||||
TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"`
|
||||
// 已退数量:0=未退,=Quantity 表示整行已退单
|
||||
ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"`
|
||||
BatchNo string `gorm:"size:50" json:"batch_no"`
|
||||
ProductionDate *Date `gorm:"type:date" json:"production_date"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
|
||||
@@ -155,6 +155,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
|
||||
// 撤回(审核中→草稿):管理员/超管任意单,操作员限本人单(handler 内判权)
|
||||
stockIn.PUT("/orders/:id/withdraw", stockInH.Withdraw)
|
||||
// 退单(已审核单退货,库存删除+冲应付):仅管理员/超管(service 内判权)
|
||||
stockIn.POST("/orders/:id/return", stockInH.Return)
|
||||
}
|
||||
|
||||
// 出库
|
||||
@@ -170,6 +172,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
|
||||
// 撤回(审核中→草稿):管理员/超管任意单,操作员限本人单(handler 内判权)
|
||||
stockOut.PUT("/orders/:id/withdraw", stockOutH.Withdraw)
|
||||
// 退单(已审核单退货,库存加回+冲应收):管理员/超管 或 本人(service 内判权)
|
||||
stockOut.POST("/orders/:id/return", stockOutH.Return)
|
||||
}
|
||||
|
||||
// 库存
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -327,6 +327,7 @@ func SetupTestDB() *gorm.DB {
|
||||
order_date DATETIME,
|
||||
total_amount REAL DEFAULT 0,
|
||||
reviewed_at DATETIME,
|
||||
return_state TEXT DEFAULT 'none',
|
||||
custom_fields TEXT,
|
||||
remark TEXT
|
||||
)`,
|
||||
@@ -346,6 +347,7 @@ func SetupTestDB() *gorm.DB {
|
||||
quantity REAL NOT NULL,
|
||||
unit_price REAL DEFAULT 0,
|
||||
total_price REAL DEFAULT 0,
|
||||
returned_quantity REAL DEFAULT 0,
|
||||
batch_no TEXT,
|
||||
production_date DATETIME,
|
||||
custom_fields TEXT,
|
||||
@@ -368,6 +370,7 @@ func SetupTestDB() *gorm.DB {
|
||||
order_date DATETIME,
|
||||
total_amount REAL DEFAULT 0,
|
||||
reviewed_at DATETIME,
|
||||
return_state TEXT DEFAULT 'none',
|
||||
custom_fields TEXT,
|
||||
remark TEXT
|
||||
)`,
|
||||
@@ -387,6 +390,7 @@ func SetupTestDB() *gorm.DB {
|
||||
quantity REAL NOT NULL,
|
||||
unit_price REAL DEFAULT 0,
|
||||
total_price REAL DEFAULT 0,
|
||||
returned_quantity REAL DEFAULT 0,
|
||||
batch_no TEXT,
|
||||
production_date DATETIME,
|
||||
custom_fields TEXT,
|
||||
|
||||
Reference in New Issue
Block a user