bindbox-game/internal/service/finance/profit_metrics.go
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

85 lines
2.1 KiB
Go

package finance
import "strings"
const defaultMultiplierX1000 int64 = 1000
type SpendingBreakdown struct {
PaidCoupon int64
GamePass int64
Total int64
IsGamePass bool
}
// ClassifyOrderSpending applies the unified rule:
// - game pass order: spending = game pass value
// - normal order: spending = actual_amount only (real cash received)
//
// Note: discount_amount (coupon) and points_amount are FREE marketing subsidies,
// NOT real revenue. They are tracked as display-only fields separately.
func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, discountAmount int64, remark string, gamePassValue int64) SpendingBreakdown {
isGamePass := IsGamePassOrder(sourceType, orderNo, actualAmount, remark)
if isGamePass {
if gamePassValue < 0 {
gamePassValue = 0
}
return SpendingBreakdown{
PaidCoupon: 0,
GamePass: gamePassValue,
Total: gamePassValue,
IsGamePass: true,
}
}
cashRevenue := actualAmount
if cashRevenue < 0 {
cashRevenue = 0
}
return SpendingBreakdown{
PaidCoupon: cashRevenue,
GamePass: 0,
Total: cashRevenue,
IsGamePass: false,
}
}
func IsGamePassOrder(sourceType int32, orderNo string, actualAmount int64, remark string) bool {
if sourceType == 4 {
return true
}
if strings.HasPrefix(orderNo, "GP") {
return true
}
return actualAmount == 0 && strings.Contains(remark, "use_game_pass")
}
func ComputeGamePassValue(drawCount, activityPrice int64) int64 {
if drawCount <= 0 || activityPrice <= 0 {
return 0
}
return drawCount * activityPrice
}
func NormalizeMultiplierX1000(multiplierX1000 int64) int64 {
if multiplierX1000 <= 0 {
return defaultMultiplierX1000
}
return multiplierX1000
}
func ComputePrizeCostWithMultiplier(baseCost, multiplierX1000 int64) int64 {
if baseCost <= 0 {
return 0
}
n := NormalizeMultiplierX1000(multiplierX1000)
return baseCost * n / defaultMultiplierX1000
}
func ComputeProfit(spending, prizeCost int64) (int64, float64) {
profit := spending - prizeCost
if spending <= 0 {
return profit, 0
}
return profit, float64(profit) / float64(spending)
}