package finance 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. 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 { ActivityID int64 SourceType int32 OrderNo string ActualAmount int64 DiscountAmount int64 Remark string DrawCount int64 ActivityPrice int64 } var revenueRows []activityRevenueRow q := s.dbR.WithContext(ctx). Table(model.TableNameOrders). Select(`activity_issues.activity_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_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") if len(params.ActivityIDs) > 0 { q = q.Where("activity_issues.activity_id IN ?", params.ActivityIDs) } if params.StartTime != nil { q = q.Where("orders.created_at >= ?", *params.StartTime) } if params.EndTime != nil { q = q.Where("orders.created_at <= ?", *params.EndTime) } if err := q.Scan(&revenueRows).Error; err != nil { return nil, fmt.Errorf("QueryActivityProfitLoss revenue scan: %w", err) } resultMap := make(map[int64]*ProfitLossDetail) for _, r := range revenueRows { gpValue := ComputeGamePassValue(r.DrawCount, r.ActivityPrice) bd := ClassifyOrderSpending(r.SourceType, r.OrderNo, r.ActualAmount, r.DiscountAmount, r.Remark, gpValue) if _, ok := resultMap[r.ActivityID]; !ok { resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID} } resultMap[r.ActivityID].Revenue += bd.Total } // Step 2: Inventory cost scan — grouped by activity_id, multiplier applied in Go type activityInventoryRow struct { ActivityID int64 ValueCents int64 MultiplierX1000 int64 } iq := s.dbR.WithContext(ctx). Table(model.TableNameUserInventory). Select(`user_inventory.activity_id, user_inventory.value_cents, COALESCE(system_item_cards.reward_multiplier_x1000, 1000) as multiplier_x1000`). 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("(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) } if params.StartTime != nil { iq = iq.Where("user_inventory.created_at >= ?", *params.StartTime) } if params.EndTime != nil { iq = iq.Where("user_inventory.created_at <= ?", *params.EndTime) } var inventoryRows []activityInventoryRow if err := iq.Scan(&inventoryRows).Error; err != nil { return nil, fmt.Errorf("QueryActivityProfitLoss inventory cost scan: %w", err) } for _, r := range inventoryRows { cost := ComputePrizeCostWithMultiplier(r.ValueCents, r.MultiplierX1000) if _, ok := resultMap[r.ActivityID]; !ok { resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID} } 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 details := make([]ProfitLossDetail, 0, len(resultMap)) var totalRevenue, totalCost int64 for _, d := range resultMap { d.Profit, d.ProfitRate = ComputeProfit(d.Revenue, d.Cost) totalRevenue += d.Revenue totalCost += d.Cost details = append(details, *d) } totalProfit, profitRate := ComputeProfit(totalRevenue, totalCost) return &ProfitLossResult{ TotalRevenue: totalRevenue, TotalCost: totalCost, TotalProfit: totalProfit, ProfitRate: profitRate, Details: details, Breakdown: []interface{}{}, }, nil }