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
+31
View File
@@ -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"})
}
+31
View File
@@ -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_return85→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")