- 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>
202 lines
7.1 KiB
Go
202 lines
7.1 KiB
Go
package finance
|
|
|
|
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.
|
|
func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) {
|
|
// Step 1: Revenue scan — per-order rows classified in Go
|
|
type userRevenueRow struct {
|
|
UserID int64
|
|
SourceType int32
|
|
OrderNo string
|
|
ActualAmount int64
|
|
DiscountAmount int64
|
|
Remark string
|
|
DrawCount int64
|
|
ActivityPrice int64
|
|
}
|
|
var revenueRows []userRevenueRow
|
|
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,
|
|
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")
|
|
if len(params.UserIDs) > 0 {
|
|
q = q.Where("orders.user_id IN ?", params.UserIDs)
|
|
}
|
|
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("QueryUserProfitLoss 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.UserID]; !ok {
|
|
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
|
}
|
|
resultMap[r.UserID].Revenue += bd.Total
|
|
}
|
|
|
|
// Step 2: Inventory cost scan — apply multiplier in Go (not SQL, for SQLite compat)
|
|
type userInventoryRow struct {
|
|
UserID int64
|
|
ValueCents int64
|
|
MultiplierX1000 int64
|
|
}
|
|
iq := s.dbR.WithContext(ctx).
|
|
Table(model.TableNameUserInventory).
|
|
Select(`user_inventory.user_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.UserIDs) > 0 {
|
|
iq = iq.Where("user_inventory.user_id IN ?", params.UserIDs)
|
|
}
|
|
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 []userInventoryRow
|
|
if err := iq.Scan(&inventoryRows).Error; err != nil {
|
|
return nil, fmt.Errorf("QueryUserProfitLoss inventory cost scan: %w", err)
|
|
}
|
|
for _, r := range inventoryRows {
|
|
cost := ComputePrizeCostWithMultiplier(r.ValueCents, r.MultiplierX1000)
|
|
if _, ok := resultMap[r.UserID]; !ok {
|
|
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
|
}
|
|
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
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|