feat(backend): 出入库定价字段消歧 + 总利润落库 + 成本仅管理员可见
- 字段重命名(旧列弃用保留,启动幂等回填 BackfillPricingColumns): 入库 unit_price/total_price/total_amount → cost_price/cost_amount/cost_total; 出库同前缀 + 新增 sale_amount(售价小计)/ sale_total(应收)/ profit_total(总利润) - 建单落三值;确认售价联动重算 sale_amount/sale_total/profit_total; 确认进价回填成本后联动重算受影响出库单利润(recalcStockOutProfit) - 出库 List/Get 对 operator/readonly 抹零成本与利润(stripStockOutCost 服务端兜底) - 兼容一版:Create/Update 接受旧 key unit_price 回落(v1.0.87 及之前客户端) - SUM 聚合/价格趋势/导入/种子工具同步切新列;测试全量改名 + 6 个新回归 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
@@ -27,6 +27,22 @@ func NewStockOutHandler(db *gorm.DB, svc *service.StockService) *StockOutHandler
|
||||
}
|
||||
|
||||
// List GET /api/v1/stock-out/orders
|
||||
// stripStockOutCost 服务端兜底:成本/利润仅管理员可见(2026-07 用户拍板)。
|
||||
// operator/readonly 的 List/Get 响应把 cost_price/cost_amount/profit_total 抹零,
|
||||
// 防止非管理员通过抓包看到成本与利润(前端同时隐藏对应列)。
|
||||
func stripStockOutCost(role string, orders []model.StockOutOrder) {
|
||||
if role == "admin" || role == "superadmin" {
|
||||
return
|
||||
}
|
||||
for i := range orders {
|
||||
orders[i].ProfitTotal = 0
|
||||
for j := range orders[i].Items {
|
||||
orders[i].Items[j].CostPrice = 0
|
||||
orders[i].Items[j].CostAmount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StockOutHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
@@ -78,6 +94,7 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Order("order_date DESC, id DESC").Find(&orders)
|
||||
|
||||
stripStockOutCost(middleware.GetRole(c), orders)
|
||||
c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
@@ -92,7 +109,7 @@ func (h *StockOutHandler) Summary(c *gin.Context) {
|
||||
}
|
||||
h.db.Model(&model.StockOutOrder{}).
|
||||
Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to).
|
||||
Select("COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS amt").Scan(&r)
|
||||
Select("COUNT(*) AS cnt, COALESCE(SUM(sale_total),0) AS amt").Scan(&r)
|
||||
return r.Cnt, r.Amt
|
||||
}
|
||||
var s stockSummary
|
||||
@@ -150,7 +167,9 @@ func (h *StockOutHandler) Get(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, order)
|
||||
one := []model.StockOutOrder{order}
|
||||
stripStockOutCost(middleware.GetRole(c), one)
|
||||
util.RespondSuccess(c, one[0])
|
||||
}
|
||||
|
||||
// Create POST /api/v1/stock-out/orders
|
||||
@@ -231,14 +250,24 @@ func (h *StockOutHandler) Create(c *gin.Context) {
|
||||
}
|
||||
req.OrderNo = orderNo
|
||||
|
||||
var total float64
|
||||
var total, profit float64
|
||||
for i := range req.Items {
|
||||
req.Items[i].ShopID = shopID
|
||||
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
|
||||
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
||||
total += req.Items[i].Quantity * req.Items[i].SalePrice
|
||||
it := &req.Items[i]
|
||||
it.ShopID = shopID
|
||||
// 兼容旧客户端(≤1.0.87):unit_price 回落为 cost_price(新 key 优先)
|
||||
if it.CostPrice == 0 && it.LegacyUnitPrice != nil {
|
||||
it.CostPrice = *it.LegacyUnitPrice
|
||||
}
|
||||
// 成本小计 / 售价小计分列;应收(SaleTotal)与总利润按售价口径
|
||||
it.CostAmount = it.Quantity * it.CostPrice
|
||||
it.SaleAmount = it.Quantity * it.SalePrice
|
||||
total += it.SaleAmount
|
||||
if it.SalePrice > 0 {
|
||||
profit += (it.SalePrice - it.CostPrice) * it.Quantity
|
||||
}
|
||||
}
|
||||
req.TotalAmount = total
|
||||
req.SaleTotal = total
|
||||
req.ProfitTotal = profit
|
||||
|
||||
if err := fillStockOutItemSnapshots(h.db, shopID, req.Items); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -284,12 +313,20 @@ func (h *StockOutHandler) Update(c *gin.Context) {
|
||||
return err
|
||||
}
|
||||
var total float64
|
||||
var profit float64
|
||||
for i := range req.Items {
|
||||
req.Items[i].ShopID = shopID
|
||||
req.Items[i].OrderID = order.ID
|
||||
// TotalPrice 记成本小计;应收(TotalAmount)按售价×数量
|
||||
req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice
|
||||
total += req.Items[i].Quantity * req.Items[i].SalePrice
|
||||
it := &req.Items[i]
|
||||
it.ShopID = shopID
|
||||
it.OrderID = order.ID
|
||||
if it.CostPrice == 0 && it.LegacyUnitPrice != nil {
|
||||
it.CostPrice = *it.LegacyUnitPrice
|
||||
}
|
||||
it.CostAmount = it.Quantity * it.CostPrice
|
||||
it.SaleAmount = it.Quantity * it.SalePrice
|
||||
total += it.SaleAmount
|
||||
if it.SalePrice > 0 {
|
||||
profit += (it.SalePrice - it.CostPrice) * it.Quantity
|
||||
}
|
||||
}
|
||||
if err := fillStockOutItemSnapshots(tx, shopID, req.Items); err != nil {
|
||||
return err
|
||||
@@ -299,7 +336,8 @@ func (h *StockOutHandler) Update(c *gin.Context) {
|
||||
"partner_id": req.PartnerID,
|
||||
"order_date": req.OrderDate,
|
||||
"remark": req.Remark,
|
||||
"total_amount": total,
|
||||
"sale_total": total,
|
||||
"profit_total": profit,
|
||||
}
|
||||
if err := tx.Model(&order).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user