diff --git a/backend/internal/handler/stock_cost_confirm_test.go b/backend/internal/handler/stock_cost_confirm_test.go new file mode 100644 index 0000000..e35b888 --- /dev/null +++ b/backend/internal/handler/stock_cost_confirm_test.go @@ -0,0 +1,191 @@ +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" +) + +// 调货核心场景:0 价入库 → 审核 → 部分出库 → 确认进价(前向补偿)。 +func TestConfirmCost_BackfillsCostAndAdjustsPayable(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "CC_MAIN") + 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) + + // 0 价入库 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": 0.0}}, + }) + require.Equal(t, http.StatusCreated, w.Code) + 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 + require.NoError(t, db.Where("order_id = ?", inID).First(&inItem).Error) + pid := inItem.ProductID // 序列号模型:入库为该行新建独立 product + + // 库存批次成本待定(UnitPrice 为 NULL) + var inv model.Inventory + require.NoError(t, db.Where("shop_id = ? AND stock_in_item_id = ?", shop.ID, inItem.ID).First(&inv).Error) + assert.Nil(t, inv.UnitPrice, "0 价入库时库存成本应为待定(NULL)") + + // 出库 4(售价 80,成本暂为 0)→ 审核 + 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": pid, "quantity": 4.0, "unit_price": 0.0, "sale_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) + + // 确认进价 50 + w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inID), adminToken, + map[string]interface{}{"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 50.0}}}) + require.Equal(t, http.StatusOK, w.Code) + + // 1. 入库明细成本回填 + db.First(&inItem, inItem.ID) + assert.Equal(t, 50.0, inItem.UnitPrice) + assert.Equal(t, 500.0, inItem.TotalPrice) + + // 2. 剩余库存批次成本回填(6 件) + db.First(&inv, inv.ID) + require.NotNil(t, inv.UnitPrice) + assert.Equal(t, 50.0, *inv.UnitPrice) + + // 3. 已出库行成本快照回填,售价/应收不变 + var outItem model.StockOutItem + require.NoError(t, db.Where("order_id = ?", outID).First(&outItem).Error) + assert.Equal(t, 50.0, outItem.UnitPrice, "已出库成本应回填") + assert.Equal(t, 200.0, outItem.TotalPrice, "成本小计 = 50×4") + assert.Equal(t, 80.0, outItem.SalePrice, "售价不应被改动") + + var outOrder model.StockOutOrder + db.First(&outOrder, outID) + assert.Equal(t, 320.0, outOrder.TotalAmount, "应收按售价 4×80,确认进价不影响") + + // 4. 入库单总额按差额重算:0 → 500 + var inOrder model.StockInOrder + db.First(&inOrder, inID) + assert.Equal(t, 500.0, inOrder.TotalAmount) + + // 5. 应付差额调整流水:+500(balance 是应收应付混合滚动总账:0 入 + 320 应收 + 500 = 820) + var adj model.FinanceRecord + require.NoError(t, db.Where("shop_id = ? AND type = 'payable' AND ref_type = 'stock_in_cost_adjust'", shop.ID). + First(&adj).Error) + assert.Equal(t, 500.0, adj.Amount) + assert.Equal(t, 820.0, adj.Balance) + + // 6. product 参考进价同步(pid 是入库新建的独立 product) + var p2 model.Product + require.NoError(t, db.First(&p2, pid).Error) + assert.Equal(t, 50.0, p2.PurchasePrice) +} + +// 非管理员无权确认进价。 +func TestConfirmCost_Forbidden(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "CC_FORBID") + 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": 5.0, "unit_price": 0.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) + + w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inID), opToken, + map[string]interface{}{"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 30.0}}}) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +// 仅 approved 单可确认;草稿/待审核拒绝。 +func TestConfirmCost_RejectsNonApproved(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "CC_DRAFT") + 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) + + 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": 5.0, "unit_price": 0.0}}, + }) + inID := extractID(w) + var inItem model.StockInItem + db.Where("order_id = ?", inID).First(&inItem) + + // 仍是 draft → 400 + w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inID), adminToken, + map[string]interface{}{"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 30.0}}}) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// 重复确认:按新旧差额增量补偿,不重复全量。 +func TestConfirmCost_RepeatableByDiff(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "CC_REPEAT") + 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) + + 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": 0.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) + + // 第一次确认 50(diff 500) + makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inID), adminToken, + map[string]interface{}{"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 50.0}}}) + // 第二次修正为 60(diff 100) + w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inID), adminToken, + map[string]interface{}{"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 60.0}}}) + require.Equal(t, http.StatusOK, w.Code) + + db.First(&inItem, inItem.ID) + assert.Equal(t, 60.0, inItem.UnitPrice) + + var inOrder model.StockInOrder + db.First(&inOrder, inID) + assert.Equal(t, 600.0, inOrder.TotalAmount, "总额 = 60×10") + + // 两条调整流水:+500 与 +100,余额滚动到 600 + var adjs []model.FinanceRecord + db.Where("shop_id = ? AND ref_type = 'stock_in_cost_adjust'", shop.ID).Order("id ASC").Find(&adjs) + require.Len(t, adjs, 2) + assert.Equal(t, 500.0, adjs[0].Amount) + assert.Equal(t, 100.0, adjs[1].Amount) + assert.Equal(t, 600.0, adjs[1].Balance) +} diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index 6416762..b96ad8d 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -315,3 +315,41 @@ func (h *StockInHandler) Return(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "returned"}) } + +// ConfirmCost POST /api/v1/stock-in/orders/:id/confirm-cost +// 确认进价:调货等以 0 价(暂估)入库并已审核后,价格确定时补填真实进价。 +// 前向补偿(更新库存成本、回填已出库成本快照、补应付差额),不反审核。 +// 仅 approved 单、管理员/超管(service 内判权)。 +func (h *StockInHandler) ConfirmCost(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 { + Items []service.CostConfirmItem `json:"items"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if len(req.Items) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "未提供要确认的明细"}) + return + } + for _, it := range req.Items { + if it.UnitPrice <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "进价必须大于 0"}) + return + } + } + if err := h.stockSvc.ConfirmStockInCost(shopID, id, userID, role, req.Items); 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": "cost confirmed"}) +} diff --git a/backend/internal/handler/testhelper_test.go b/backend/internal/handler/testhelper_test.go index 25f2c58..1a03065 100644 --- a/backend/internal/handler/testhelper_test.go +++ b/backend/internal/handler/testhelper_test.go @@ -66,6 +66,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine { stockIn.PUT("/orders/:id/reject", stockInH.Reject) stockIn.PUT("/orders/:id/withdraw", stockInH.Withdraw) stockIn.POST("/orders/:id/return", stockInH.Return) + stockIn.POST("/orders/:id/confirm-cost", stockInH.ConfirmCost) // 出库路由 stockOut := api.Group("/stock-out") diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4622f14..d7a1923 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -157,6 +157,8 @@ func Setup(r *gin.Engine, db *gorm.DB) { stockIn.PUT("/orders/:id/withdraw", stockInH.Withdraw) // 退单(已审核单退货,库存删除+冲应付):仅管理员/超管(service 内判权) stockIn.POST("/orders/:id/return", stockInH.Return) + // 确认进价(暂估0价→真实价,前向补偿):仅管理员/超管(service 内判权) + stockIn.POST("/orders/:id/confirm-cost", stockInH.ConfirmCost) } // 出库 diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index 940522f..5f48770 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -157,6 +157,111 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error }) } +// CostConfirmItem 是「确认进价」的单行入参:把某入库明细的进价补成真实值。 +type CostConfirmItem struct { + ItemID uint64 `json:"item_id"` + UnitPrice float64 `json:"unit_price"` +} + +// ConfirmStockInCost 「确认进价」:调货等场景以 0 价(暂估)入库并已审核/已出库后, +// 价格确定时补填真实进价。不反审核、不撤销任何已发生的动作,而是在一个事务内 +// 前向写补偿: +// 1. 入库明细 UnitPrice / TotalPrice 改为真实值 +// 2. 该明细对应的剩余库存批次 Inventory.UnitPrice 由 NULL→真实值 +// 3. 该 product 已出库行 StockOutItem.UnitPrice/TotalPrice(成本快照)回填真实值 +// (product↔入库明细 1:1,按 product_id 精确命中;不动 SalePrice/应收) +// 4. 入库单 TotalAmount 按差额重算 +// 5. 对供应商应付按差额补一条调整流水(滚动余额) +// +// 仅 approved 单可确认(draft 直接编辑即可);权限:管理员/超管。 +// 以「旧价→新价」差额计算补偿,支持多次修正(再次确认按新旧差额补)。 +func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role string, items []CostConfirmItem) 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 := make(map[uint64]float64, len(items)) + for _, it := range items { + want[it.ItemID] = it.UnitPrice + } + + now := time.Now() + var totalDiff float64 + + for i := range order.Items { + it := &order.Items[i] + newPrice, ok := want[it.ID] + if !ok || newPrice == it.UnitPrice { + continue + } + oldPrice := it.UnitPrice + diff := (newPrice - oldPrice) * it.Quantity + totalDiff += diff + + // 1. 入库明细 + if err := tx.Model(it).Updates(map[string]interface{}{ + "unit_price": newPrice, + "total_price": newPrice * it.Quantity, + }).Error; err != nil { + return err + } + + // 2. 该明细的剩余库存批次(已全部出库则无行,跳过) + if err := tx.Model(&model.Inventory{}). + Where("shop_id = ? AND stock_in_item_id = ? AND deleted_at IS NULL", shopID, it.ID). + Update("unit_price", newPrice).Error; err != nil { + return err + } + + // 3. 已出库行成本快照回填(按 product_id 精确命中;product_id=0 的历史导入单不传播) + if it.ProductID != 0 { + if err := tx.Model(&model.StockOutItem{}). + Where("shop_id = ? AND product_id = ?", shopID, it.ProductID). + Updates(map[string]interface{}{ + "unit_price": newPrice, + "total_price": gorm.Expr("? * quantity", newPrice), + }).Error; err != nil { + return err + } + } + + // 同步 product 参考进价 + if it.ProductID != 0 { + tx.Model(&model.Product{}).Where("id = ? AND shop_id = ?", it.ProductID, shopID). + Update("purchase_price", newPrice) + } + } + + if totalDiff == 0 { + return nil + } + + // 4. 入库单总额按差额重算 + if err := tx.Model(&order). + Update("total_amount", gorm.Expr("total_amount + ?", totalDiff)).Error; err != nil { + return err + } + + // 5. 应付差额调整流水(滚动余额) + bal := partnerLastBalance(tx, shopID, order.PartnerID) + totalDiff + oid := order.ID + return tx.Create(&model.FinanceRecord{ + ShopID: shopID, PartnerID: order.PartnerID, Type: "payable", + Amount: totalDiff, Balance: bal, Status: "open", + RefType: "stock_in_cost_adjust", RefID: &oid, OperatorID: userID, RecordDate: now, + }).Error + }) +} + // ApproveStockOut 审核出库单,FIFO 扣减批次库存 func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error { return s.db.Transaction(func(tx *gorm.DB) error {