fix(dashboard): 按有效抽奖口径修正产品动销排行

本次仅调整产品动销排行接口的后端统计逻辑,不改动活动盈亏分析页面与接口。

详细说明:
- 将销量从 activity_draw_logs 总条数改为有效抽奖次数,仅统计 orders.status = 2 的抽奖记录,避免把退款、取消等无效抽奖一并计入。
- 将营收统一改为“有效抽奖次数 × 活动单抽价格(price_draw)”,不再使用总日志数乘单价,确保产品动销排行内部次数与营收口径一致。
- 将成本从 price_snapshot_cents / products.price 的近似标价口径改为 products.cost_price 成本价口径,并继续保留 drop_quantity 与倍数卡 multiplier 的影响,避免利润长期被标价成本压低。
- 将盈亏与利润率统一为 Revenue - Cost 与 Profit / Revenue,使产品动销排行内部的销量、营收、成本、盈亏逻辑自洽。
- 将贡献率从按次数占比调整为按营收占比,更符合“营收贡献率”的业务含义。

本次未处理内容:
- 不修改活动盈亏分析的现有统计口径。
- 不修改前端卡片结构与活动盈亏分析页面。

已核对的业务结论:
- 活动 103 的产品动销排行 4696 次是最近 30 天有效抽奖次数;活动盈亏分析 5960 次是全量历史有效抽奖次数,两边差异来自时间范围而非当前产品动销排行次数公式。
This commit is contained in:
Zuncle 2026-04-24 21:35:36 +08:00
parent 400cd68d00
commit e6e4214df4

View File

@ -1855,18 +1855,44 @@ type productPerformanceItem struct {
func (h *handler) OperationsProductPerformance() core.HandlerFunc {
return func(ctx core.Context) {
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
// 按活动聚合抽奖数据
type drawRow struct {
ActivityID int64 `gorm:"column:activity_id"`
Count int64 `gorm:"column:count"`
TotalCost int64 `gorm:"column:total_cost"`
type performanceRow struct {
ActivityID int64 `gorm:"column:activity_id"`
SalesCount int64 `gorm:"column:sales_count"`
PaymentCount int64 `gorm:"column:payment_count"`
GamePassCount int64 `gorm:"column:game_pass_count"`
TotalCost int64 `gorm:"column:total_cost"`
PriceDraw int64 `gorm:"column:price_draw"`
RevenueCents int64 `gorm:"column:revenue_cents"`
ContributionBase int64 `gorm:"column:contribution_base"`
}
var rows []drawRow
var rows []performanceRow
// 统计抽奖日志,按活动分组,并计算奖品成本
if err := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
if err := db.Table(model.TableNameActivityDrawLogs).
Select(`
activity_issues.activity_id,
SUM(CASE WHEN orders.status = 2 AND (
orders.source_type = 4
OR orders.order_no LIKE 'GP%'
OR (orders.actual_amount = 0 AND COALESCE(orders.remark, '') LIKE '%use_game_pass%')
) THEN 1 ELSE 0 END) as game_pass_count,
SUM(CASE WHEN orders.status = 2 AND NOT (
orders.source_type = 4
OR orders.order_no LIKE 'GP%'
OR (orders.actual_amount = 0 AND COALESCE(orders.remark, '') LIKE '%use_game_pass%')
) THEN 1 ELSE 0 END) as payment_count,
SUM(CASE WHEN orders.status = 2 THEN 1 ELSE 0 END) as sales_count,
COALESCE(MAX(activities.price_draw), 0) as price_draw,
SUM(CASE WHEN orders.status = 2 THEN COALESCE(activities.price_draw, 0) ELSE 0 END) as revenue_cents,
SUM(CASE WHEN orders.status = 2 THEN COALESCE(activities.price_draw, 0) ELSE 0 END) as contribution_base,
CAST(SUM(CASE WHEN orders.status = 2 THEN COALESCE(products.cost_price, 0) * (
COALESCE(NULLIF(activity_reward_settings.drop_quantity, 0), 1) +
CASE WHEN user_item_cards.used_draw_log_id = activity_draw_logs.id AND system_item_cards.effect_type = 1 AND system_item_cards.reward_multiplier_x1000 >= 2000 THEN 1 ELSE 0 END
) ELSE 0 END) AS SIGNED) as total_cost
`).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id").
Joins("LEFT JOIN orders ON orders.id = activity_draw_logs.order_id").
@ -1874,41 +1900,33 @@ func (h *handler) OperationsProductPerformance() core.HandlerFunc {
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Where("activity_draw_logs.created_at >= ?", s).
Where("activity_draw_logs.created_at <= ?", e).
Select(
"activity_issues.activity_id",
"COUNT(activity_draw_logs.id) as count",
"CAST(SUM(IF(activity_draw_logs.is_winner = 1, COALESCE(activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000, 0)) AS SIGNED) as total_cost",
).
Group("activity_issues.activity_id").
Order("count DESC").
Order("sales_count DESC").
Limit(10).
Scan(&rows).Error; err != nil {
h.logger.Error(fmt.Sprintf("OperationsProductPerformance draw cost stats error: %v", err))
h.logger.Error(fmt.Sprintf("OperationsProductPerformance stats error: %v", err))
}
// 获取活动详情(名称和单价)
activityIDs := make([]int64, len(rows))
for i, r := range rows {
activityIDs[i] = r.ActivityID
}
type actInfo struct {
Name string
PriceDraw int64
Name string
}
actMap := make(map[int64]actInfo)
if len(activityIDs) > 0 {
acts, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.Activities.ID.In(activityIDs...)).Find()
for _, a := range acts {
actMap[a.ID] = actInfo{Name: a.Name, PriceDraw: a.PriceDraw}
actMap[a.ID] = actInfo{Name: a.Name}
}
}
// 计算总数用于贡献率
var totalCount int64
var totalRevenueCents int64
for _, r := range rows {
totalCount += r.Count
totalRevenueCents += r.ContributionBase
}
out := make([]productPerformanceItem, len(rows))
@ -1916,30 +1934,29 @@ func (h *handler) OperationsProductPerformance() core.HandlerFunc {
info := actMap[r.ActivityID]
var contribution float64
if totalCount > 0 {
contribution = float64(r.Count) / float64(totalCount) * 100
if totalRevenueCents > 0 {
contribution = float64(r.ContributionBase) / float64(totalRevenueCents) * 100
}
// 周转率简化计算
days := e.Sub(s).Hours() / 24
if days < 1 {
days = 1
}
turnover := float64(r.Count) / days * 7
turnover := float64(r.SalesCount) / days * 7
profitCents := r.RevenueCents - r.TotalCost
out[i] = productPerformanceItem{
ID: r.ActivityID,
SeriesName: info.Name,
SalesCount: r.Count,
Amount: (r.Count * info.PriceDraw) / 100, // 转换为元
Profit: (r.Count*info.PriceDraw - r.TotalCost) / 100,
SalesCount: r.SalesCount,
Amount: r.RevenueCents / 100,
Profit: profitCents / 100,
ProfitRate: 0,
ContributionRate: float64(int(contribution*10)) / 10.0,
InventoryTurnover: float64(int(turnover*10)) / 10.0,
}
if r.Count > 0 && info.PriceDraw > 0 {
revenue := r.Count * info.PriceDraw
pr := float64(revenue-r.TotalCost) / float64(revenue) * 100
if r.RevenueCents > 0 {
pr := float64(profitCents) / float64(r.RevenueCents) * 100
out[i].ProfitRate = float64(int(pr*10)) / 10.0
}
}