From 20f9e3a410131f8ebfbc75f9d3c8be09a6d082dc Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 21 Jun 2026 22:17:24 +0800 Subject: [PATCH] chore: release server-v1.0.74 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx --- CHANGELOG-server.md | 8 + backend/internal/handler/stock_in.go | 31 +++ backend/internal/handler/stock_out.go | 31 +++ backend/internal/handler/stock_return_test.go | 190 +++++++++++++++ backend/internal/handler/testhelper_test.go | 2 + backend/internal/model/stock.go | 8 + backend/internal/router/router.go | 4 + backend/internal/service/stock.go | 221 ++++++++++++++++++ backend/testutil/setup.go | 4 + 9 files changed, 499 insertions(+) create mode 100644 backend/internal/handler/stock_return_test.go diff --git a/CHANGELOG-server.md b/CHANGELOG-server.md index 2fb6c18..be09af4 100644 --- a/CHANGELOG-server.md +++ b/CHANGELOG-server.md @@ -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 ### 改进 diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index 7dfbdfb..860a16f 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -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"}) +} diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index 096906d..4fd9a7c 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -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"}) +} diff --git a/backend/internal/handler/stock_return_test.go b/backend/internal/handler/stock_return_test.go new file mode 100644 index 0000000..4018513 --- /dev/null +++ b/backend/internal/handler/stock_return_test.go @@ -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) +} diff --git a/backend/internal/handler/testhelper_test.go b/backend/internal/handler/testhelper_test.go index 195b0e7..a48b0de 100644 --- a/backend/internal/handler/testhelper_test.go +++ b/backend/internal/handler/testhelper_test.go @@ -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") diff --git a/backend/internal/model/stock.go b/backend/internal/model/stock.go index b89745b..77418ec 100644 --- a/backend/internal/model/stock.go +++ b/backend/internal/model/stock.go @@ -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"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index fdb577c..4622f14 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) } // 库存 diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index 04a3c1a..c65ed21 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -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 diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 7000a65..f020f7b 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -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,