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
133 lines
5.3 KiB
Go
133 lines
5.3 KiB
Go
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
|
||
}
|