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
This commit is contained in:
Zuncle 2026-03-26 00:01:17 +08:00
parent be245c1476
commit 58fd926b46
12 changed files with 140 additions and 310 deletions

View File

@ -172,7 +172,7 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
SourceType: 3, // 对对碰
TotalAmount: activity.PriceDraw,
ActualAmount: 0, // 次数卡抵扣实付0元
DiscountAmount: activity.PriceDraw,
DiscountAmount: 0, // 次数卡支付,无优惠券抵扣
Status: 2, // 已支付
Remark: func() string {
r := fmt.Sprintf("activity:%d|game_pass:%d|matching_game:issue:%d", activity.ID, validPass.ID, req.IssueID)

View File

@ -121,7 +121,7 @@ func (h *handler) GetActivityRankings() core.HandlerFunc {
orders.user_id,
COALESCE(users.nickname, '') AS nickname,
COALESCE(users.avatar, '') AS avatar,
CAST(SUM(orders.actual_amount + orders.discount_amount) AS SIGNED) AS total_amount,
CAST(SUM(orders.actual_amount) AS SIGNED) AS total_amount,
COUNT(DISTINCT orders.id) AS order_count
`).
Group("orders.user_id, users.nickname, users.avatar")

View File

@ -168,126 +168,38 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
}
}
// 3. 统计营收和优惠券抵扣 (通过 orders 关联 activity_draw_logs)
// 3. 统计营收和优惠券抵扣 (通过 orders 关联 activity_draw_logs)
// BUG修复排除已退款订单(status=4)。
// 注意: MySQL SUM()运算涉及除法时会返回Decimal类型需要Scan到float64
type revenueStat struct {
ActivityID int64
TotalRevenue float64
TotalDiscount float64
}
var revenueStats []revenueStat
// 修正: 按抽奖次数比例分摊订单金额,且次卡订单不计入支付+优惠券口径(严格二选一)
var err error
err = db.Table(model.TableNameOrders).
Select(`
order_activity_draws.activity_id,
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN 0
ELSE 1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count
END) as total_revenue,
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN 0
ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count
END) as total_discount
`).
// Subquery 1: Calculate draw counts per order per activity (and link to issue->activity)
Joins(`JOIN (
SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count
FROM activity_draw_logs
JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id
GROUP BY activity_draw_logs.order_id, activity_issues.activity_id
) as order_activity_draws ON order_activity_draws.order_id = orders.id`).
// Subquery 2: Calculate total draw counts per order
Joins(`JOIN (
SELECT order_id, COUNT(*) as total_count
FROM activity_draw_logs
GROUP BY order_id
) as order_total_draws ON order_total_draws.order_id = orders.id`).
Where("orders.status = ?", 2). // 已支付(排除待支付、取消、退款状态)
Where("order_activity_draws.activity_id IN ?", activityIDs).
Group("order_activity_draws.activity_id").
Scan(&revenueStats).Error
if err != nil {
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss revenue stats error: %v", err))
}
for _, s := range revenueStats {
if item, ok := activityMap[s.ActivityID]; ok {
item.TotalRevenue = int64(s.TotalRevenue)
item.TotalDiscount = int64(s.TotalDiscount)
}
}
// 4. 从 finance.Service 获取成本(替换原有直接 SQL 成本查询)
// finance.Service 用 value_cents 作为单一真相源D-09无需 COALESCE fallback chain
// 3. 从 finance.Service 统一获取收入、成本和展示字段
// Revenue = actual_amount (真实现金)
// Cost = inventory value × item card multiplier
// 展示字段: CouponDiscount, PointsDiscount, GamePassValue
financeParams := financesvc.ActivityProfitLossParams{
ActivityIDs: activityIDs,
}
financeResult, financeErr := h.financeSvc.QueryActivityProfitLoss(ctx.RequestContext(), financeParams)
if financeErr != nil {
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss finance cost error: %v", financeErr))
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss finance error: %v", financeErr))
}
// 按 activity_id 建立 cost 索引
financeCostMap := make(map[int64]int64)
if financeResult != nil {
for _, d := range financeResult.Details {
financeCostMap[d.ActivityID] = d.Cost
}
}
for actID, item := range activityMap {
if cost, ok := financeCostMap[actID]; ok {
item.TotalCost = cost
item.PrizeCostFinal = cost
if item, ok := activityMap[d.ActivityID]; ok {
item.TotalRevenue = d.Revenue
item.TotalCost = d.Cost
item.PrizeCostFinal = d.Cost
item.TotalDiscount = d.CouponDiscount + d.PointsDiscount
item.TotalGamePassValue = d.GamePassValue
}
}
}
// 5. 统计次卡价值 (0元订单按活动单价计算)
// 先获取各活动的单价
activityPriceMap := make(map[int64]int64)
for _, a := range activities {
activityPriceMap[a.ID] = a.PriceDraw
}
// 统计每个活动的0元订单对应的抽奖次数 (次卡支付)
// BUG修复之前统计的是订单数量但一个订单可能包含多次抽奖
// 正确做法是统计抽奖次数,再乘以活动单价
type gamePassStat struct {
ActivityID int64
GamePassDraws int64 // 抽奖次数,非订单数
}
var gamePassStats []gamePassStat
db.Table(model.TableNameActivityDrawLogs).
Select("activity_issues.activity_id, COUNT(activity_draw_logs.id) as game_pass_draws").
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("JOIN orders ON orders.id = activity_draw_logs.order_id").
Where("orders.status = ? AND orders.status != ?", 2, 4). // 已支付且未退款
Where("orders.actual_amount = 0"). // 0元订单
Where("orders.source_type = 4 OR orders.order_no LIKE 'GP%'"). // 次数卡 (Lottery SourceType=4 OR Matching Game GP prefix)
Where("activity_issues.activity_id IN ?", activityIDs).
Group("activity_issues.activity_id").
Scan(&gamePassStats)
for _, s := range gamePassStats {
if item, ok := activityMap[s.ActivityID]; ok {
// 次卡价值 = 次卡抽奖次数 * 活动单价
item.TotalGamePassValue = s.GamePassDraws * activityPriceMap[s.ActivityID]
}
}
// 6. 计算盈亏和比率
// 成本来自 finance.Servicevalue_cents 单一真相源 + 道具卡倍率)
// 收入来自原有 scan保留 total_discount / total_game_pass_value 拆分字段)
// 4. 计算盈亏和比率
// Revenue = 现金到账, Cost = 奖品成本
// Profit = Revenue - Cost (优惠券/积分/次卡不参与利润计算)
finalList := make([]activityProfitLossItem, 0, len(activities))
for _, a := range activities {
item := activityMap[a.ID]
item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
item.SpendingPaidCoupon = item.TotalRevenue
item.SpendingGamePass = item.TotalGamePassValue
totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass
item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost)
item.Profit, item.ProfitRate = financesvc.ComputeProfit(item.TotalRevenue, item.TotalCost)
finalList = append(finalList, *item)
}
@ -609,11 +521,11 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
if drawCount <= 0 {
drawCount = 1
}
perDrawOrderAmount := l.OrderAmount / drawCount
perDrawDiscountAmount := l.DiscountAmount / drawCount
perDrawPointsAmount := l.PointsAmount / drawCount
perDrawOrderAmount := l.OrderAmount / drawCount // actual_amount 分摊(现金)
perDrawDiscountAmount := l.DiscountAmount / drawCount // 展示用
perDrawPointsAmount := l.PointsAmount / drawCount // 展示用
// 次卡单口径:仅记次卡价值,不再叠加 discount避免次卡+现金”双计
// 次卡单口径:仅记次卡价值,不再叠加 discount避免次卡+现金”双计
if isGamePassOrder {
if l.ActivityPrice > 0 {
perDrawOrderAmount = l.ActivityPrice
@ -626,6 +538,9 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
paymentDetails.CouponDiscount = perDrawDiscountAmount
paymentDetails.PointsDiscount = perDrawPointsAmount
// 计算单次抽奖成本:使用 value_cents × 道具卡倍率(与 finance service 一致)
prizeCost := financesvc.ComputePrizeCostWithMultiplier(l.ProductPrice, int64(l.Multiplier)*1000)
list[i] = activityLogItem{
ID: l.ID,
UserID: l.UserID,
@ -636,13 +551,13 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
ProductImage: productImage,
ProductPrice: l.ProductPrice,
ProductQuantity: quantity,
OrderAmount: perDrawOrderAmount, // 单次抽奖分摊的支付金额
OrderAmount: perDrawOrderAmount, // 单次抽奖分摊的现金金额
OrderNo: l.OrderNo, // 订单号
DiscountAmount: perDrawDiscountAmount, // 单次抽奖分摊的优惠金额
DiscountAmount: perDrawDiscountAmount, // 单次抽奖分摊的优惠金额(展示用)
PayType: payType,
UsedCard: usedCard,
OrderStatus: l.OrderStatus,
Profit: perDrawOrderAmount + perDrawDiscountAmount - l.ProductPrice*quantity, // 单次盈亏 = 分摊收入 - 成本*数量
Profit: perDrawOrderAmount - prizeCost, // 单次盈亏 = 现金收入 - 奖品成本(含倍率)
CreatedAt: l.CreatedAt,
PaymentDetails: paymentDetails,
}

View File

@ -123,11 +123,11 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
}
if err := query.Select(`
orders.user_id,
orders.user_id,
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
ELSE orders.actual_amount + orders.discount_amount
END) as total_amount,
ELSE orders.actual_amount
END) as total_amount,
COUNT(orders.id) as order_count,
SUM(orders.discount_amount) as total_discount,
SUM(orders.points_amount) as total_points,
@ -140,21 +140,21 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
SUM(CASE WHEN oa.category_id = 1 THEN
CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
ELSE orders.actual_amount + orders.discount_amount
ELSE orders.actual_amount
END
ELSE 0 END) as ichiban_spending,
SUM(CASE WHEN oa.category_id = 1 THEN 1 ELSE 0 END) as ichiban_count,
SUM(CASE WHEN oa.category_id = 2 THEN
CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
ELSE orders.actual_amount + orders.discount_amount
ELSE orders.actual_amount
END
ELSE 0 END) as infinite_spending,
SUM(CASE WHEN oa.category_id = 2 THEN 1 ELSE 0 END) as infinite_count,
SUM(CASE WHEN oa.category_id = 3 THEN
CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
ELSE orders.actual_amount + orders.discount_amount
ELSE orders.actual_amount
END
ELSE 0 END) as matching_spending,
SUM(CASE WHEN oa.category_id = 3 THEN 1 ELSE 0 END) as matching_count,
@ -261,9 +261,10 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
query = query.Where("user_inventory.created_at >= ?", start).
Where("user_inventory.created_at <= ?", end)
}
// Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2).
// Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2) and redeemed-to-points (3+redeemed).
query = query.Where("user_inventory.status IN ?", []int{1, 3}).
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
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%")
err := query.Select(`
user_inventory.user_id,
@ -339,7 +340,8 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Where("user_inventory.user_id IN ?", userIDs).
Where("user_inventory.status IN (1,3)").
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
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%")
if req.RangeType != "all" {
invQ = invQ.Where("user_inventory.created_at >= ?", start.Add(-2*time.Hour)).
Where("user_inventory.created_at <= ?", end.Add(24*time.Hour))

View File

@ -115,7 +115,7 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
CAST(ROUND(SUM(CASE
WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' OR (orders.actual_amount = 0 AND orders.remark LIKE '%use_game_pass%')
THEN COALESCE(order_activity_draws.draw_count * activities.price_draw, 0)
ELSE COALESCE((orders.actual_amount + orders.discount_amount) * order_activity_draws.draw_count / NULLIF(order_total_draws.total_count, 0), 0)
ELSE COALESCE(orders.actual_amount * order_activity_draws.draw_count / NULLIF(order_total_draws.total_count, 0), 0)
END), 0) AS SIGNED) as spending,
COUNT(DISTINCT orders.id) as order_count
`).
@ -143,7 +143,9 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Where("user_inventory.user_id = ?", userID).
Where("user_inventory.status IN ?", []int{1, 3}).
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
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("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id, 0) > 0")
if hasRange {
prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end)
@ -243,7 +245,8 @@ func (h *handler) GetUserSpendingDashboard() core.HandlerFunc {
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
Where("user_id = ?", userID).
Where("status IN (1,3)").
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%")
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%")
if hasRange {
invQ = invQ.Where("created_at >= ?", start.Add(-2*time.Hour)).Where("created_at <= ?", end.Add(24*time.Hour))
}

View File

@ -209,6 +209,7 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
LEFT JOIN system_item_cards sic ON sic.id = uic.card_id
WHERE ui.user_id = ? AND ui.status IN (1, 3)
AND COALESCE(ui.remark, '') NOT LIKE '%%void%%'
AND NOT (ui.status = 3 AND (COALESCE(ui.remark, '') LIKE '%%redeemed_points%%' OR COALESCE(ui.remark, '') LIKE '%%batch_redeemed%%'))
`, userID).Scan(&is).Error
rsp.CurrentAssets.InventoryCount = is.Count
rsp.CurrentAssets.InventoryValue = is.Value

View File

@ -100,6 +100,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
LEFT JOIN system_item_cards sic ON sic.id = uic.card_id
WHERE ui.user_id = ? AND ui.status IN (1, 3)
AND COALESCE(ui.remark, '') NOT LIKE '%%void%%'
AND NOT (ui.status = 3 AND (COALESCE(ui.remark, '') LIKE '%%redeemed_points%%' OR COALESCE(ui.remark, '') LIKE '%%batch_redeemed%%'))
`, userID).Scan(&curAssets.Products).Error
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(sc.price), 0) FROM user_item_cards uic LEFT JOIN system_item_cards sc ON sc.id = uic.card_id WHERE uic.user_id = ? AND uic.status = 1", userID).Scan(&curAssets.Cards).Error
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(balance_amount), 0) FROM user_coupons WHERE user_id = ? AND status = 1", userID).Scan(&curAssets.Coupons).Error
@ -112,7 +113,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
SELECT COALESCE(SUM(CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
ELSE o.actual_amount
END), 0)
FROM orders o
LEFT JOIN (
@ -148,7 +149,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
ELSE o.actual_amount
END as spending
FROM orders o
LEFT JOIN (
@ -230,7 +231,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
SELECT COALESCE(SUM(CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
ELSE o.actual_amount
END), 0)
FROM orders o
LEFT JOIN (
@ -505,7 +506,7 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
CASE
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
THEN COALESCE(od.draw_count * a.price_draw, 0)
ELSE o.actual_amount + o.discount_amount
ELSE o.actual_amount
END as spending
FROM orders o
LEFT JOIN (
@ -532,7 +533,7 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
couponValue := couponValueMap[o.ID]
spending := orderSpendingMap[o.ID]
if spending == 0 {
spending = o.ActualAmount + o.DiscountAmount
spending = o.ActualAmount
}
netCost := spending - refund
if netCost < 0 {

View File

@ -13,7 +13,10 @@ type SpendingBreakdown struct {
// ClassifyOrderSpending applies the unified rule:
// - game pass order: spending = game pass value
// - normal order: spending = actual + discount
// - 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 {
@ -28,14 +31,14 @@ func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, disco
}
}
paidCoupon := actualAmount + discountAmount
if paidCoupon < 0 {
paidCoupon = 0
cashRevenue := actualAmount
if cashRevenue < 0 {
cashRevenue = 0
}
return SpendingBreakdown{
PaidCoupon: paidCoupon,
PaidCoupon: cashRevenue,
GamePass: 0,
Total: paidCoupon,
Total: cashRevenue,
IsGamePass: false,
}
}

View File

@ -4,13 +4,15 @@ import (
"context"
"fmt"
"bindbox-game/internal/pkg/points"
"bindbox-game/internal/repository/mysql/model"
)
// queryActivity implements QueryActivityProfitLoss using fan-out + in-memory merge.
// Four independent Scan() calls gather revenue, inventory cost, points cost,
// and coupon cost attributed to activity dimension; results merged in Go.
// Two independent Scan() calls gather revenue and inventory cost
// attributed to activity dimension; results merged in Go.
//
// Revenue = actual_amount only (real cash). Coupons/points are FREE marketing subsidies.
// Cost = inventory value × item card multiplier only.
func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) {
// Step 1: Revenue scan — per-order rows attributed to activity via draw logs
type activityRevenueRow struct {
@ -19,6 +21,7 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
OrderNo string
ActualAmount int64
DiscountAmount int64
PointsAmount int64
Remark string
DrawCount int64
ActivityPrice int64
@ -28,14 +31,14 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
Table(model.TableNameOrders).
Select(`activity_issues.activity_id,
orders.source_type, orders.order_no,
orders.actual_amount, orders.discount_amount, orders.remark,
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("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
Joins("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, activity_issues.activity_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark")
Group("orders.id, activity_issues.activity_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.points_amount, orders.remark")
if len(params.ActivityIDs) > 0 {
q = q.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
}
@ -56,7 +59,16 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
if _, ok := resultMap[r.ActivityID]; !ok {
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
}
resultMap[r.ActivityID].Revenue += bd.Total
d := resultMap[r.ActivityID]
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 — grouped by activity_id, multiplier applied in Go
@ -68,13 +80,16 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
iq := s.dbR.WithContext(ctx).
Table(model.TableNameUserInventory).
Select(`user_inventory.activity_id,
user_inventory.value_cents,
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.ActivityIDs) > 0 {
iq = iq.Where("user_inventory.activity_id IN ?", params.ActivityIDs)
@ -97,78 +112,7 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
resultMap[r.ActivityID].Cost += cost
}
// Step 3: Points cost scan — link via orders → draw_logs → activity
type activityPointsRow struct {
ActivityID int64
TotalPoints int64
}
pq := s.dbR.WithContext(ctx).
Table(model.TableNameUserPointsLedger).
Select("activity_issues.activity_id, SUM(-user_points_ledger.points) as total_points").
Joins("JOIN orders ON orders.order_no = user_points_ledger.ref_id AND user_points_ledger.ref_table = 'orders'").
Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Where("user_points_ledger.action = ?", "order_deduct").
Where("user_points_ledger.points < ?", 0).
Where("orders.status = ?", 2)
if len(params.ActivityIDs) > 0 {
pq = pq.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
}
if params.StartTime != nil {
pq = pq.Where("user_points_ledger.created_at >= ?", *params.StartTime)
}
if params.EndTime != nil {
pq = pq.Where("user_points_ledger.created_at <= ?", *params.EndTime)
}
pq = pq.Group("activity_issues.activity_id")
var pointsRows []activityPointsRow
if err := pq.Scan(&pointsRows).Error; err != nil {
return nil, fmt.Errorf("QueryActivityProfitLoss points cost scan: %w", err)
}
rate := s.getPointsExchangeRate(ctx)
for _, r := range pointsRows {
costCents := points.PointsToCents(r.TotalPoints, float64(rate))
if _, ok := resultMap[r.ActivityID]; !ok {
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
}
resultMap[r.ActivityID].Cost += costCents
}
// Step 4: Coupon cost scan — link via orders → draw_logs → activity
type activityCouponRow struct {
ActivityID int64
TotalCost int64
}
cq := s.dbR.WithContext(ctx).
Table(model.TableNameUserCouponLedger).
Select("activity_issues.activity_id, SUM(-user_coupon_ledger.change_amount) as total_cost").
Joins("JOIN orders ON orders.id = user_coupon_ledger.order_id").
Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Where("user_coupon_ledger.change_amount < ?", 0).
Where("orders.status = ?", 2)
if len(params.ActivityIDs) > 0 {
cq = cq.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
}
if params.StartTime != nil {
cq = cq.Where("user_coupon_ledger.created_at >= ?", *params.StartTime)
}
if params.EndTime != nil {
cq = cq.Where("user_coupon_ledger.created_at <= ?", *params.EndTime)
}
cq = cq.Group("activity_issues.activity_id")
var couponRows []activityCouponRow
if err := cq.Scan(&couponRows).Error; err != nil {
return nil, fmt.Errorf("QueryActivityProfitLoss coupon cost scan: %w", err)
}
for _, r := range couponRows {
if _, ok := resultMap[r.ActivityID]; !ok {
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
}
resultMap[r.ActivityID].Cost += r.TotalCost
}
// Step 5: Apply ComputeProfit per detail and aggregate totals
// Step 3: Apply ComputeProfit per detail and aggregate totals
details := make([]ProfitLossDetail, 0, len(resultMap))
var totalRevenue, totalCost int64
for _, d := range resultMap {

View File

@ -4,13 +4,15 @@ import (
"context"
"fmt"
"bindbox-game/internal/pkg/points"
"bindbox-game/internal/repository/mysql/model"
)
// queryUser implements QueryUserProfitLoss using fan-out + in-memory merge pattern.
// Four independent Scan() calls gather revenue, inventory cost, points cost,
// and coupon cost; results are merged in Go via map[int64]*ProfitLossDetail.
// 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 {
@ -19,6 +21,7 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
OrderNo string
ActualAmount int64
DiscountAmount int64
PointsAmount int64
Remark string
DrawCount int64
ActivityPrice int64
@ -27,14 +30,14 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
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.remark,
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.remark")
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)
}
@ -55,7 +58,16 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
if _, ok := resultMap[r.UserID]; !ok {
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
}
resultMap[r.UserID].Revenue += bd.Total
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)
@ -67,13 +79,16 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
iq := s.dbR.WithContext(ctx).
Table(model.TableNameUserInventory).
Select(`user_inventory.user_id,
user_inventory.value_cents,
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)
@ -96,72 +111,7 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
resultMap[r.UserID].Cost += cost
}
// Step 3: Points cost scan
type userPointsRow struct {
UserID int64
TotalPoints int64
}
pq := s.dbR.WithContext(ctx).
Table(model.TableNameUserPointsLedger).
Select("user_id, SUM(-points) as total_points").
Where("action = ?", "order_deduct").
Where("points < ?", 0)
if len(params.UserIDs) > 0 {
pq = pq.Where("user_id IN ?", params.UserIDs)
}
if params.StartTime != nil {
pq = pq.Where("created_at >= ?", *params.StartTime)
}
if params.EndTime != nil {
pq = pq.Where("created_at <= ?", *params.EndTime)
}
pq = pq.Group("user_id")
var pointsRows []userPointsRow
if err := pq.Scan(&pointsRows).Error; err != nil {
return nil, fmt.Errorf("QueryUserProfitLoss points cost scan: %w", err)
}
rate := s.getPointsExchangeRate(ctx)
for _, r := range pointsRows {
costCents := points.PointsToCents(r.TotalPoints, float64(rate))
if _, ok := resultMap[r.UserID]; !ok {
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
}
resultMap[r.UserID].Cost += costCents
}
// Step 4: Coupon cost scan — join to paid orders
type userCouponRow struct {
UserID int64
TotalCost int64
}
cq := s.dbR.WithContext(ctx).
Table(model.TableNameUserCouponLedger).
Select("user_coupon_ledger.user_id, SUM(-user_coupon_ledger.change_amount) as total_cost").
Joins("LEFT JOIN orders ON orders.id = user_coupon_ledger.order_id").
Where("user_coupon_ledger.change_amount < ?", 0).
Where("orders.status = ?", 2)
if len(params.UserIDs) > 0 {
cq = cq.Where("user_coupon_ledger.user_id IN ?", params.UserIDs)
}
if params.StartTime != nil {
cq = cq.Where("user_coupon_ledger.created_at >= ?", *params.StartTime)
}
if params.EndTime != nil {
cq = cq.Where("user_coupon_ledger.created_at <= ?", *params.EndTime)
}
cq = cq.Group("user_coupon_ledger.user_id")
var couponRows []userCouponRow
if err := cq.Scan(&couponRows).Error; err != nil {
return nil, fmt.Errorf("QueryUserProfitLoss coupon cost scan: %w", err)
}
for _, r := range couponRows {
if _, ok := resultMap[r.UserID]; !ok {
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
}
resultMap[r.UserID].Cost += r.TotalCost
}
// Step 5: Apply ComputeProfit per detail and aggregate totals
// Step 3: Apply ComputeProfit per detail and aggregate totals
details := make([]ProfitLossDetail, 0, len(resultMap))
var totalRevenue, totalCost int64
for _, d := range resultMap {
@ -180,22 +130,3 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
Breakdown: []interface{}{},
}, nil
}
// getPointsExchangeRate reads system_configs for the points exchange rate.
// Falls back to 1 (1 yuan = 1 point) on any error.
func (s *service) getPointsExchangeRate(ctx context.Context) int64 {
var cfg struct{ ConfigValue string }
if err := s.dbR.WithContext(ctx).
Table("system_configs").
Select("config_value").
Where("config_key = ?", "points.exchange_rate").
First(&cfg).Error; err != nil {
return 1
}
var rate int64
fmt.Sscanf(cfg.ConfigValue, "%d", &rate)
if rate <= 0 {
return 1
}
return rate
}

View File

@ -34,10 +34,16 @@ type ActivityProfitLossParams struct {
type ProfitLossDetail struct {
UserID int64 // populated for user dimension queries
ActivityID int64 // populated for activity dimension queries
Revenue int64 // fen (RET-03: int64 only, no float64 for monetary)
Cost int64 // fen
Revenue int64 // fen — actual_amount only (real cash received)
Cost int64 // fen — inventory value × item card multiplier
Profit int64 // fen
ProfitRate float64 // ratio; only float64 field for monetary concept
// Display-only fields — NOT included in Revenue/Cost/Profit calculation.
// These are FREE marketing subsidies the platform gave away.
CouponDiscount int64 // fen — total coupon discount (orders.discount_amount)
PointsDiscount int64 // fen — total points discount (orders.points_amount)
GamePassValue int64 // fen — total game pass value (draw_count × price_draw)
}
// ProfitLossResult — aggregated P&L result (RET-01)

View File

@ -0,0 +1,24 @@
-- 修复对对碰次数卡订单的幽灵 discount_amount
-- 问题matching_game_app.go 中次数卡下单时DiscountAmount 被错误地设为活动全价
-- 实际上次数卡支付不涉及优惠券discount_amount 应该为 0
-- 影响:所有 discount_amount 汇总统计会虚高
-- 代码修复matching_game_app.go L175 DiscountAmount: activity.PriceDraw → 0
-- Step 1: 先查看受影响的数据量DRY RUN
-- SELECT COUNT(*) as affected_count,
-- SUM(discount_amount) as total_phantom_discount_cents,
-- SUM(discount_amount) / 100.0 as total_phantom_discount_yuan
-- FROM orders
-- WHERE source_type = 3
-- AND actual_amount = 0
-- AND discount_amount > 0
-- AND (order_no LIKE 'GP%' OR remark LIKE '%game_pass%');
-- Step 2: 执行修复
UPDATE orders
SET discount_amount = 0,
updated_at = NOW(3)
WHERE source_type = 3
AND actual_amount = 0
AND discount_amount > 0
AND (order_no LIKE 'GP%' OR remark LIKE '%game_pass%');