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:
parent
be245c1476
commit
58fd926b46
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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.Service(value_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,
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
24
migrations/20260325_fix_matching_gamepass_discount.sql
Normal file
24
migrations/20260325_fix_matching_gamepass_discount.sql
Normal 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%');
|
||||
Loading…
x
Reference in New Issue
Block a user