bindbox-game/internal/service/finance/query_activity.go
win 2a7b731484 feat(finance): implement Phase 1 core P&L service + wire into dashboard
- Add internal/service/finance/types.go: AssetType enum, param/result structs
- Add internal/service/finance/service.go: Service interface, read-only ctor
- Add internal/service/finance/query_user.go: QueryUserProfitLoss (4 fan-out scans)
- Add internal/service/finance/query_activity.go: QueryActivityProfitLoss (4 fan-out scans)
- Add internal/service/finance/service_test.go: 22 integration tests (all pass)
- Wire finance.Service into admin handler (admin.go)
- Replace dashboard_activity cost scan with finance.Service call (D-09: value_cents single source of truth)
- Revenue/gamepass/draw-count scans unchanged; response schema fully compatible

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-21 18:38:33 +08:00

190 lines
7.5 KiB
Go

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
}