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, // 对对碰
|
SourceType: 3, // 对对碰
|
||||||
TotalAmount: activity.PriceDraw,
|
TotalAmount: activity.PriceDraw,
|
||||||
ActualAmount: 0, // 次数卡抵扣,实付0元
|
ActualAmount: 0, // 次数卡抵扣,实付0元
|
||||||
DiscountAmount: activity.PriceDraw,
|
DiscountAmount: 0, // 次数卡支付,无优惠券抵扣
|
||||||
Status: 2, // 已支付
|
Status: 2, // 已支付
|
||||||
Remark: func() string {
|
Remark: func() string {
|
||||||
r := fmt.Sprintf("activity:%d|game_pass:%d|matching_game:issue:%d", activity.ID, validPass.ID, req.IssueID)
|
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,
|
orders.user_id,
|
||||||
COALESCE(users.nickname, '') AS nickname,
|
COALESCE(users.nickname, '') AS nickname,
|
||||||
COALESCE(users.avatar, '') AS avatar,
|
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
|
COUNT(DISTINCT orders.id) AS order_count
|
||||||
`).
|
`).
|
||||||
Group("orders.user_id, users.nickname, users.avatar")
|
Group("orders.user_id, users.nickname, users.avatar")
|
||||||
|
|||||||
@ -168,126 +168,38 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 统计营收和优惠券抵扣 (通过 orders 关联 activity_draw_logs)
|
// 3. 从 finance.Service 统一获取收入、成本和展示字段
|
||||||
// 3. 统计营收和优惠券抵扣 (通过 orders 关联 activity_draw_logs)
|
// Revenue = actual_amount (真实现金)
|
||||||
// BUG修复:排除已退款订单(status=4)。
|
// Cost = inventory value × item card multiplier
|
||||||
// 注意: MySQL SUM()运算涉及除法时会返回Decimal类型,需要Scan到float64
|
// 展示字段: CouponDiscount, PointsDiscount, GamePassValue
|
||||||
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
|
|
||||||
financeParams := financesvc.ActivityProfitLossParams{
|
financeParams := financesvc.ActivityProfitLossParams{
|
||||||
ActivityIDs: activityIDs,
|
ActivityIDs: activityIDs,
|
||||||
}
|
}
|
||||||
financeResult, financeErr := h.financeSvc.QueryActivityProfitLoss(ctx.RequestContext(), financeParams)
|
financeResult, financeErr := h.financeSvc.QueryActivityProfitLoss(ctx.RequestContext(), financeParams)
|
||||||
if financeErr != nil {
|
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 {
|
if financeResult != nil {
|
||||||
for _, d := range financeResult.Details {
|
for _, d := range financeResult.Details {
|
||||||
financeCostMap[d.ActivityID] = d.Cost
|
if item, ok := activityMap[d.ActivityID]; ok {
|
||||||
}
|
item.TotalRevenue = d.Revenue
|
||||||
}
|
item.TotalCost = d.Cost
|
||||||
for actID, item := range activityMap {
|
item.PrizeCostFinal = d.Cost
|
||||||
if cost, ok := financeCostMap[actID]; ok {
|
item.TotalDiscount = d.CouponDiscount + d.PointsDiscount
|
||||||
item.TotalCost = cost
|
item.TotalGamePassValue = d.GamePassValue
|
||||||
item.PrizeCostFinal = cost
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 统计次卡价值 (0元订单按活动单价计算)
|
// 4. 计算盈亏和比率
|
||||||
// 先获取各活动的单价
|
// Revenue = 现金到账, Cost = 奖品成本
|
||||||
activityPriceMap := make(map[int64]int64)
|
// Profit = Revenue - Cost (优惠券/积分/次卡不参与利润计算)
|
||||||
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 拆分字段)
|
|
||||||
finalList := make([]activityProfitLossItem, 0, len(activities))
|
finalList := make([]activityProfitLossItem, 0, len(activities))
|
||||||
for _, a := range activities {
|
for _, a := range activities {
|
||||||
item := activityMap[a.ID]
|
item := activityMap[a.ID]
|
||||||
item.SpendingPaidCoupon = item.TotalRevenue + item.TotalDiscount
|
item.SpendingPaidCoupon = item.TotalRevenue
|
||||||
item.SpendingGamePass = item.TotalGamePassValue
|
item.SpendingGamePass = item.TotalGamePassValue
|
||||||
totalIncome := item.SpendingPaidCoupon + item.SpendingGamePass
|
item.Profit, item.ProfitRate = financesvc.ComputeProfit(item.TotalRevenue, item.TotalCost)
|
||||||
item.Profit, item.ProfitRate = financesvc.ComputeProfit(totalIncome, item.TotalCost)
|
|
||||||
finalList = append(finalList, *item)
|
finalList = append(finalList, *item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -609,11 +521,11 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
|||||||
if drawCount <= 0 {
|
if drawCount <= 0 {
|
||||||
drawCount = 1
|
drawCount = 1
|
||||||
}
|
}
|
||||||
perDrawOrderAmount := l.OrderAmount / drawCount
|
perDrawOrderAmount := l.OrderAmount / drawCount // actual_amount 分摊(现金)
|
||||||
perDrawDiscountAmount := l.DiscountAmount / drawCount
|
perDrawDiscountAmount := l.DiscountAmount / drawCount // 展示用
|
||||||
perDrawPointsAmount := l.PointsAmount / drawCount
|
perDrawPointsAmount := l.PointsAmount / drawCount // 展示用
|
||||||
|
|
||||||
// 次卡单口径:仅记次卡价值,不再叠加 discount,避免“次卡+现金”双计
|
// 次卡单口径:仅记次卡价值,不再叠加 discount,避免”次卡+现金”双计
|
||||||
if isGamePassOrder {
|
if isGamePassOrder {
|
||||||
if l.ActivityPrice > 0 {
|
if l.ActivityPrice > 0 {
|
||||||
perDrawOrderAmount = l.ActivityPrice
|
perDrawOrderAmount = l.ActivityPrice
|
||||||
@ -626,6 +538,9 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
|||||||
paymentDetails.CouponDiscount = perDrawDiscountAmount
|
paymentDetails.CouponDiscount = perDrawDiscountAmount
|
||||||
paymentDetails.PointsDiscount = perDrawPointsAmount
|
paymentDetails.PointsDiscount = perDrawPointsAmount
|
||||||
|
|
||||||
|
// 计算单次抽奖成本:使用 value_cents × 道具卡倍率(与 finance service 一致)
|
||||||
|
prizeCost := financesvc.ComputePrizeCostWithMultiplier(l.ProductPrice, int64(l.Multiplier)*1000)
|
||||||
|
|
||||||
list[i] = activityLogItem{
|
list[i] = activityLogItem{
|
||||||
ID: l.ID,
|
ID: l.ID,
|
||||||
UserID: l.UserID,
|
UserID: l.UserID,
|
||||||
@ -636,13 +551,13 @@ func (h *handler) DashboardActivityLogs() core.HandlerFunc {
|
|||||||
ProductImage: productImage,
|
ProductImage: productImage,
|
||||||
ProductPrice: l.ProductPrice,
|
ProductPrice: l.ProductPrice,
|
||||||
ProductQuantity: quantity,
|
ProductQuantity: quantity,
|
||||||
OrderAmount: perDrawOrderAmount, // 单次抽奖分摊的支付金额
|
OrderAmount: perDrawOrderAmount, // 单次抽奖分摊的现金金额
|
||||||
OrderNo: l.OrderNo, // 订单号
|
OrderNo: l.OrderNo, // 订单号
|
||||||
DiscountAmount: perDrawDiscountAmount, // 单次抽奖分摊的优惠金额
|
DiscountAmount: perDrawDiscountAmount, // 单次抽奖分摊的优惠金额(展示用)
|
||||||
PayType: payType,
|
PayType: payType,
|
||||||
UsedCard: usedCard,
|
UsedCard: usedCard,
|
||||||
OrderStatus: l.OrderStatus,
|
OrderStatus: l.OrderStatus,
|
||||||
Profit: perDrawOrderAmount + perDrawDiscountAmount - l.ProductPrice*quantity, // 单次盈亏 = 分摊收入 - 成本*数量
|
Profit: perDrawOrderAmount - prizeCost, // 单次盈亏 = 现金收入 - 奖品成本(含倍率)
|
||||||
CreatedAt: l.CreatedAt,
|
CreatedAt: l.CreatedAt,
|
||||||
PaymentDetails: paymentDetails,
|
PaymentDetails: paymentDetails,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,11 +123,11 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := query.Select(`
|
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%')
|
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)
|
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
|
||||||
ELSE orders.actual_amount + orders.discount_amount
|
ELSE orders.actual_amount
|
||||||
END) as total_amount,
|
END) as total_amount,
|
||||||
COUNT(orders.id) as order_count,
|
COUNT(orders.id) as order_count,
|
||||||
SUM(orders.discount_amount) as total_discount,
|
SUM(orders.discount_amount) as total_discount,
|
||||||
SUM(orders.points_amount) as total_points,
|
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
|
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%')
|
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)
|
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
|
||||||
ELSE orders.actual_amount + orders.discount_amount
|
ELSE orders.actual_amount
|
||||||
END
|
END
|
||||||
ELSE 0 END) as ichiban_spending,
|
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 = 1 THEN 1 ELSE 0 END) as ichiban_count,
|
||||||
SUM(CASE WHEN oa.category_id = 2 THEN
|
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%')
|
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)
|
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
|
||||||
ELSE orders.actual_amount + orders.discount_amount
|
ELSE orders.actual_amount
|
||||||
END
|
END
|
||||||
ELSE 0 END) as infinite_spending,
|
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 = 2 THEN 1 ELSE 0 END) as infinite_count,
|
||||||
SUM(CASE WHEN oa.category_id = 3 THEN
|
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%')
|
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)
|
THEN COALESCE(oa.price_draw * oa.draw_count, 0)
|
||||||
ELSE orders.actual_amount + orders.discount_amount
|
ELSE orders.actual_amount
|
||||||
END
|
END
|
||||||
ELSE 0 END) as matching_spending,
|
ELSE 0 END) as matching_spending,
|
||||||
SUM(CASE WHEN oa.category_id = 3 THEN 1 ELSE 0 END) as matching_count,
|
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).
|
query = query.Where("user_inventory.created_at >= ?", start).
|
||||||
Where("user_inventory.created_at <= ?", end)
|
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}).
|
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(`
|
err := query.Select(`
|
||||||
user_inventory.user_id,
|
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").
|
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||||
Where("user_inventory.user_id IN ?", userIDs).
|
Where("user_inventory.user_id IN ?", userIDs).
|
||||||
Where("user_inventory.status IN (1,3)").
|
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" {
|
if req.RangeType != "all" {
|
||||||
invQ = invQ.Where("user_inventory.created_at >= ?", start.Add(-2*time.Hour)).
|
invQ = invQ.Where("user_inventory.created_at >= ?", start.Add(-2*time.Hour)).
|
||||||
Where("user_inventory.created_at <= ?", end.Add(24*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
|
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%')
|
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)
|
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,
|
END), 0) AS SIGNED) as spending,
|
||||||
COUNT(DISTINCT orders.id) as order_count
|
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").
|
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.user_id = ?", userID).
|
||||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
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 {
|
if hasRange {
|
||||||
prizeQuery = prizeQuery.Where("user_inventory.created_at >= ?", start).Where("user_inventory.created_at <= ?", end)
|
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").
|
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||||
Where("user_id = ?", userID).
|
Where("user_id = ?", userID).
|
||||||
Where("status IN (1,3)").
|
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 {
|
if hasRange {
|
||||||
invQ = invQ.Where("created_at >= ?", start.Add(-2*time.Hour)).Where("created_at <= ?", end.Add(24*time.Hour))
|
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
|
LEFT JOIN system_item_cards sic ON sic.id = uic.card_id
|
||||||
WHERE ui.user_id = ? AND ui.status IN (1, 3)
|
WHERE ui.user_id = ? AND ui.status IN (1, 3)
|
||||||
AND COALESCE(ui.remark, '') NOT LIKE '%%void%%'
|
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
|
`, userID).Scan(&is).Error
|
||||||
rsp.CurrentAssets.InventoryCount = is.Count
|
rsp.CurrentAssets.InventoryCount = is.Count
|
||||||
rsp.CurrentAssets.InventoryValue = is.Value
|
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
|
LEFT JOIN system_item_cards sic ON sic.id = uic.card_id
|
||||||
WHERE ui.user_id = ? AND ui.status IN (1, 3)
|
WHERE ui.user_id = ? AND ui.status IN (1, 3)
|
||||||
AND COALESCE(ui.remark, '') NOT LIKE '%%void%%'
|
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
|
`, 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(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
|
_ = 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
|
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%')
|
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)
|
THEN COALESCE(od.draw_count * a.price_draw, 0)
|
||||||
ELSE o.actual_amount + o.discount_amount
|
ELSE o.actual_amount
|
||||||
END), 0)
|
END), 0)
|
||||||
FROM orders o
|
FROM orders o
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@ -148,7 +149,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
|||||||
CASE
|
CASE
|
||||||
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
|
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)
|
THEN COALESCE(od.draw_count * a.price_draw, 0)
|
||||||
ELSE o.actual_amount + o.discount_amount
|
ELSE o.actual_amount
|
||||||
END as spending
|
END as spending
|
||||||
FROM orders o
|
FROM orders o
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@ -230,7 +231,7 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
|||||||
SELECT COALESCE(SUM(CASE
|
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%')
|
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)
|
THEN COALESCE(od.draw_count * a.price_draw, 0)
|
||||||
ELSE o.actual_amount + o.discount_amount
|
ELSE o.actual_amount
|
||||||
END), 0)
|
END), 0)
|
||||||
FROM orders o
|
FROM orders o
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@ -505,7 +506,7 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
|
|||||||
CASE
|
CASE
|
||||||
WHEN o.source_type = 4 OR o.order_no LIKE 'GP%' OR (o.actual_amount = 0 AND o.remark LIKE '%use_game_pass%')
|
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)
|
THEN COALESCE(od.draw_count * a.price_draw, 0)
|
||||||
ELSE o.actual_amount + o.discount_amount
|
ELSE o.actual_amount
|
||||||
END as spending
|
END as spending
|
||||||
FROM orders o
|
FROM orders o
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
@ -532,7 +533,7 @@ func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
|
|||||||
couponValue := couponValueMap[o.ID]
|
couponValue := couponValueMap[o.ID]
|
||||||
spending := orderSpendingMap[o.ID]
|
spending := orderSpendingMap[o.ID]
|
||||||
if spending == 0 {
|
if spending == 0 {
|
||||||
spending = o.ActualAmount + o.DiscountAmount
|
spending = o.ActualAmount
|
||||||
}
|
}
|
||||||
netCost := spending - refund
|
netCost := spending - refund
|
||||||
if netCost < 0 {
|
if netCost < 0 {
|
||||||
|
|||||||
@ -13,7 +13,10 @@ type SpendingBreakdown struct {
|
|||||||
|
|
||||||
// ClassifyOrderSpending applies the unified rule:
|
// ClassifyOrderSpending applies the unified rule:
|
||||||
// - game pass order: spending = game pass value
|
// - 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 {
|
func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, discountAmount int64, remark string, gamePassValue int64) SpendingBreakdown {
|
||||||
isGamePass := IsGamePassOrder(sourceType, orderNo, actualAmount, remark)
|
isGamePass := IsGamePassOrder(sourceType, orderNo, actualAmount, remark)
|
||||||
if isGamePass {
|
if isGamePass {
|
||||||
@ -28,14 +31,14 @@ func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, disco
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
paidCoupon := actualAmount + discountAmount
|
cashRevenue := actualAmount
|
||||||
if paidCoupon < 0 {
|
if cashRevenue < 0 {
|
||||||
paidCoupon = 0
|
cashRevenue = 0
|
||||||
}
|
}
|
||||||
return SpendingBreakdown{
|
return SpendingBreakdown{
|
||||||
PaidCoupon: paidCoupon,
|
PaidCoupon: cashRevenue,
|
||||||
GamePass: 0,
|
GamePass: 0,
|
||||||
Total: paidCoupon,
|
Total: cashRevenue,
|
||||||
IsGamePass: false,
|
IsGamePass: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"bindbox-game/internal/pkg/points"
|
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// queryActivity implements QueryActivityProfitLoss using fan-out + in-memory merge.
|
// queryActivity implements QueryActivityProfitLoss using fan-out + in-memory merge.
|
||||||
// Four independent Scan() calls gather revenue, inventory cost, points cost,
|
// Two independent Scan() calls gather revenue and inventory cost
|
||||||
// and coupon cost attributed to activity dimension; results merged in Go.
|
// 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) {
|
func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) {
|
||||||
// Step 1: Revenue scan — per-order rows attributed to activity via draw logs
|
// Step 1: Revenue scan — per-order rows attributed to activity via draw logs
|
||||||
type activityRevenueRow struct {
|
type activityRevenueRow struct {
|
||||||
@ -19,6 +21,7 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
|
|||||||
OrderNo string
|
OrderNo string
|
||||||
ActualAmount int64
|
ActualAmount int64
|
||||||
DiscountAmount int64
|
DiscountAmount int64
|
||||||
|
PointsAmount int64
|
||||||
Remark string
|
Remark string
|
||||||
DrawCount int64
|
DrawCount int64
|
||||||
ActivityPrice int64
|
ActivityPrice int64
|
||||||
@ -28,14 +31,14 @@ func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossPa
|
|||||||
Table(model.TableNameOrders).
|
Table(model.TableNameOrders).
|
||||||
Select(`activity_issues.activity_id,
|
Select(`activity_issues.activity_id,
|
||||||
orders.source_type, orders.order_no,
|
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,
|
COUNT(activity_draw_logs.id) as draw_count,
|
||||||
COALESCE(MAX(activities.price_draw), 0) as activity_price`).
|
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_draw_logs ON activity_draw_logs.order_id = orders.id").
|
||||||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_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").
|
Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id").
|
||||||
Where("orders.status = ?", 2).
|
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 {
|
if len(params.ActivityIDs) > 0 {
|
||||||
q = q.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
|
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 {
|
if _, ok := resultMap[r.ActivityID]; !ok {
|
||||||
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
|
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
|
// 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).
|
iq := s.dbR.WithContext(ctx).
|
||||||
Table(model.TableNameUserInventory).
|
Table(model.TableNameUserInventory).
|
||||||
Select(`user_inventory.activity_id,
|
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`).
|
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 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 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").
|
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("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("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2)
|
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2)
|
||||||
if len(params.ActivityIDs) > 0 {
|
if len(params.ActivityIDs) > 0 {
|
||||||
iq = iq.Where("user_inventory.activity_id IN ?", params.ActivityIDs)
|
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
|
resultMap[r.ActivityID].Cost += cost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Points cost scan — link via orders → draw_logs → activity
|
// Step 3: Apply ComputeProfit per detail and aggregate totals
|
||||||
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
|
|
||||||
details := make([]ProfitLossDetail, 0, len(resultMap))
|
details := make([]ProfitLossDetail, 0, len(resultMap))
|
||||||
var totalRevenue, totalCost int64
|
var totalRevenue, totalCost int64
|
||||||
for _, d := range resultMap {
|
for _, d := range resultMap {
|
||||||
|
|||||||
@ -4,13 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"bindbox-game/internal/pkg/points"
|
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// queryUser implements QueryUserProfitLoss using fan-out + in-memory merge pattern.
|
// queryUser implements QueryUserProfitLoss using fan-out + in-memory merge pattern.
|
||||||
// Four independent Scan() calls gather revenue, inventory cost, points cost,
|
// Two independent Scan() calls gather revenue and inventory cost;
|
||||||
// and coupon cost; results are merged in Go via map[int64]*ProfitLossDetail.
|
// 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) {
|
func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) {
|
||||||
// Step 1: Revenue scan — per-order rows classified in Go
|
// Step 1: Revenue scan — per-order rows classified in Go
|
||||||
type userRevenueRow struct {
|
type userRevenueRow struct {
|
||||||
@ -19,6 +21,7 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
|
|||||||
OrderNo string
|
OrderNo string
|
||||||
ActualAmount int64
|
ActualAmount int64
|
||||||
DiscountAmount int64
|
DiscountAmount int64
|
||||||
|
PointsAmount int64
|
||||||
Remark string
|
Remark string
|
||||||
DrawCount int64
|
DrawCount int64
|
||||||
ActivityPrice int64
|
ActivityPrice int64
|
||||||
@ -27,14 +30,14 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
|
|||||||
q := s.dbR.WithContext(ctx).
|
q := s.dbR.WithContext(ctx).
|
||||||
Table(model.TableNameOrders).
|
Table(model.TableNameOrders).
|
||||||
Select(`orders.user_id, orders.source_type, orders.order_no,
|
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,
|
COUNT(activity_draw_logs.id) as draw_count,
|
||||||
COALESCE(MAX(activities.price_draw), 0) as activity_price`).
|
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_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 activity_issues ON activity_issues.id = activity_draw_logs.issue_id`).
|
||||||
Joins(`LEFT JOIN activities ON activities.id = activity_issues.activity_id`).
|
Joins(`LEFT JOIN activities ON activities.id = activity_issues.activity_id`).
|
||||||
Where("orders.status = ?", 2).
|
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 {
|
if len(params.UserIDs) > 0 {
|
||||||
q = q.Where("orders.user_id IN ?", params.UserIDs)
|
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 {
|
if _, ok := resultMap[r.UserID]; !ok {
|
||||||
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
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)
|
// 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).
|
iq := s.dbR.WithContext(ctx).
|
||||||
Table(model.TableNameUserInventory).
|
Table(model.TableNameUserInventory).
|
||||||
Select(`user_inventory.user_id,
|
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`).
|
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 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 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").
|
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("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("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2)
|
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2)
|
||||||
if len(params.UserIDs) > 0 {
|
if len(params.UserIDs) > 0 {
|
||||||
iq = iq.Where("user_inventory.user_id IN ?", params.UserIDs)
|
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
|
resultMap[r.UserID].Cost += cost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Points cost scan
|
// Step 3: Apply ComputeProfit per detail and aggregate totals
|
||||||
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
|
|
||||||
details := make([]ProfitLossDetail, 0, len(resultMap))
|
details := make([]ProfitLossDetail, 0, len(resultMap))
|
||||||
var totalRevenue, totalCost int64
|
var totalRevenue, totalCost int64
|
||||||
for _, d := range resultMap {
|
for _, d := range resultMap {
|
||||||
@ -180,22 +130,3 @@ func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*
|
|||||||
Breakdown: []interface{}{},
|
Breakdown: []interface{}{},
|
||||||
}, nil
|
}, 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 {
|
type ProfitLossDetail struct {
|
||||||
UserID int64 // populated for user dimension queries
|
UserID int64 // populated for user dimension queries
|
||||||
ActivityID int64 // populated for activity dimension queries
|
ActivityID int64 // populated for activity dimension queries
|
||||||
Revenue int64 // fen (RET-03: int64 only, no float64 for monetary)
|
Revenue int64 // fen — actual_amount only (real cash received)
|
||||||
Cost int64 // fen
|
Cost int64 // fen — inventory value × item card multiplier
|
||||||
Profit int64 // fen
|
Profit int64 // fen
|
||||||
ProfitRate float64 // ratio; only float64 field for monetary concept
|
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)
|
// 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