Zuncle 58fd926b46 fix(finance): 统一收益统计口径,修复多处数据计算错误
1. Revenue 口径统一为 actual_amount(真实现金到账)
   - 优惠券(discount_amount)和积分(points_amount)是平台免费发放的营销补贴,
     不算收入,改为展示字段
   - 涉及: profit_metrics.go, dashboard_spending.go, users_profit_loss.go,
     dashboard_user_spending.go, activity_rankings_admin.go

2. Cost 口径统一为奖品库存价值
   - 删除 finance service 中的积分成本扫描(Step 3)和优惠券成本扫描(Step 4)
   - 之前优惠券同时算在收入和成本两侧,导致利润被人为压低
   - 涉及: query_user.go, query_activity.go

3. 统一 value_cents fallback chain
   - finance service 改为与排行榜一致的三级回退:
     COALESCE(NULLIF(value_cents,0), price_snapshot_cents, products.price, 0)
   - 涉及: query_user.go, query_activity.go

4. 活动盈亏收入统一到 finance service
   - 删除 dashboard_activity.go 自有的 revenue SQL(含比例分摊逻辑)
   - 收入和成本统一由 finance.Service.QueryActivityProfitLoss() 提供
   - 修复日志明细 profit:道具卡倍率改用 ComputePrizeCostWithMultiplier

5. finance service 新增展示字段
   - ProfitLossDetail 增加 CouponDiscount, PointsDiscount, GamePassValue
   - 不参与 Revenue/Cost/Profit 计算,仅供前端展示营销补贴明细

6. 修复对对碰次卡订单 discount_amount 数据污染
   - matching_game_app.go 次卡下单时 DiscountAmount 错误设为活动全价
   - 改为 0(次卡支付不涉及优惠券)
   - 附带历史数据修复 migration SQL

7. 排除已分解奖品的成本重复计算
   - 用户可以把奖品分解成积分再兑换新商品,导致同一份价值被计算两次
   - 所有库存查询增加排除条件: status=3 且 remark 含 redeemed_points 或 batch_redeemed
   - 涉及 6 个文件的库存成本/资产查询

8. 排行榜详情抽屉限定活动范围
   - prize 查询增加 activity_id > 0 过滤,排除积分兑换/转入/合成等非活动产出
   - 使排行榜与其详情抽屉口径一致

修改文件(12个):
- internal/service/finance/profit_metrics.go
- internal/service/finance/query_user.go
- internal/service/finance/query_activity.go
- internal/service/finance/types.go
- internal/api/admin/dashboard_activity.go
- internal/api/admin/dashboard_spending.go
- internal/api/admin/dashboard_user_spending.go
- internal/api/admin/users_profit_loss.go
- internal/api/admin/users_profile.go
- internal/api/admin/activity_rankings_admin.go
- internal/api/activity/matching_game_app.go
- migrations/20260325_fix_matching_gamepass_discount.sql
2026-03-26 00:01:17 +08:00

133 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package finance
import (
"context"
"fmt"
"bindbox-game/internal/repository/mysql/model"
)
// queryUser implements QueryUserProfitLoss using fan-out + in-memory merge pattern.
// Two independent Scan() calls gather revenue and inventory cost;
// results are merged in Go via map[int64]*ProfitLossDetail.
//
// Revenue = actual_amount only (real cash). Coupons/points are FREE marketing subsidies.
// Cost = inventory value × item card multiplier only.
func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) {
// Step 1: Revenue scan — per-order rows classified in Go
type userRevenueRow struct {
UserID int64
SourceType int32
OrderNo string
ActualAmount int64
DiscountAmount int64
PointsAmount int64
Remark string
DrawCount int64
ActivityPrice int64
}
var revenueRows []userRevenueRow
q := s.dbR.WithContext(ctx).
Table(model.TableNameOrders).
Select(`orders.user_id, orders.source_type, orders.order_no,
orders.actual_amount, orders.discount_amount, orders.points_amount, orders.remark,
COUNT(activity_draw_logs.id) as draw_count,
COALESCE(MAX(activities.price_draw), 0) as activity_price`).
Joins(`LEFT JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id`).
Joins(`LEFT JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id`).
Joins(`LEFT JOIN activities ON activities.id = activity_issues.activity_id`).
Where("orders.status = ?", 2).
Group("orders.id, orders.user_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.points_amount, orders.remark")
if len(params.UserIDs) > 0 {
q = q.Where("orders.user_id IN ?", params.UserIDs)
}
if params.StartTime != nil {
q = q.Where("orders.created_at >= ?", *params.StartTime)
}
if params.EndTime != nil {
q = q.Where("orders.created_at <= ?", *params.EndTime)
}
if err := q.Scan(&revenueRows).Error; err != nil {
return nil, fmt.Errorf("QueryUserProfitLoss revenue scan: %w", err)
}
resultMap := make(map[int64]*ProfitLossDetail)
for _, r := range revenueRows {
gpValue := ComputeGamePassValue(r.DrawCount, r.ActivityPrice)
bd := ClassifyOrderSpending(r.SourceType, r.OrderNo, r.ActualAmount, r.DiscountAmount, r.Remark, gpValue)
if _, ok := resultMap[r.UserID]; !ok {
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
}
d := resultMap[r.UserID]
d.Revenue += bd.Total
// Populate display-only fields
if bd.IsGamePass {
d.GamePassValue += bd.GamePass
} else {
d.CouponDiscount += r.DiscountAmount
d.PointsDiscount += r.PointsAmount
}
}
// Step 2: Inventory cost scan — apply multiplier in Go (not SQL, for SQLite compat)
type userInventoryRow struct {
UserID int64
ValueCents int64
MultiplierX1000 int64
}
iq := s.dbR.WithContext(ctx).
Table(model.TableNameUserInventory).
Select(`user_inventory.user_id,
COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents,
COALESCE(system_item_cards.reward_multiplier_x1000, 1000) as multiplier_x1000`).
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Where("user_inventory.status IN ?", []int{1, 3}).
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
Where("NOT (user_inventory.status = 3 AND (COALESCE(user_inventory.remark, '') LIKE ? OR COALESCE(user_inventory.remark, '') LIKE ?))", "%redeemed_points%", "%batch_redeemed%").
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2)
if len(params.UserIDs) > 0 {
iq = iq.Where("user_inventory.user_id IN ?", params.UserIDs)
}
if params.StartTime != nil {
iq = iq.Where("user_inventory.created_at >= ?", *params.StartTime)
}
if params.EndTime != nil {
iq = iq.Where("user_inventory.created_at <= ?", *params.EndTime)
}
var inventoryRows []userInventoryRow
if err := iq.Scan(&inventoryRows).Error; err != nil {
return nil, fmt.Errorf("QueryUserProfitLoss inventory cost scan: %w", err)
}
for _, r := range inventoryRows {
cost := ComputePrizeCostWithMultiplier(r.ValueCents, r.MultiplierX1000)
if _, ok := resultMap[r.UserID]; !ok {
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
}
resultMap[r.UserID].Cost += cost
}
// Step 3: Apply ComputeProfit per detail and aggregate totals
details := make([]ProfitLossDetail, 0, len(resultMap))
var totalRevenue, totalCost int64
for _, d := range resultMap {
d.Profit, d.ProfitRate = ComputeProfit(d.Revenue, d.Cost)
totalRevenue += d.Revenue
totalCost += d.Cost
details = append(details, *d)
}
totalProfit, profitRate := ComputeProfit(totalRevenue, totalCost)
return &ProfitLossResult{
TotalRevenue: totalRevenue,
TotalCost: totalCost,
TotalProfit: totalProfit,
ProfitRate: profitRate,
Details: details,
Breakdown: []interface{}{},
}, nil
}