diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 8fd6cda..587a5c8 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -12,7 +12,7 @@ Two reusable service-layer functions — `QueryUserProfitLoss` and `QueryActivit Decimal phases appear between their surrounding integers in numeric order. -- [ ] **Phase 1: Core P&L Functions** - Scaffold the finance package and deliver working QueryUserProfitLoss / QueryActivityProfitLoss with correct revenue, cost, and profit +- [x] **Phase 1: Core P&L Functions** - Scaffold the finance package and deliver working QueryUserProfitLoss / QueryActivityProfitLoss with correct revenue, cost, and profit (Completed: 2026-03-21) - [ ] **Phase 2: Per-Asset-Type Breakdown** - Populate the ProfitLossBreakdown slice for all 5 asset types, including Fragment synthesis cost from its own table ## Phase Details @@ -52,5 +52,5 @@ Phases execute in numeric order: 1 → 2 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| -| 1. Core P&L Functions | 0/4 | Planning complete | - | +| 1. Core P&L Functions | 4/4 | Complete | 2026-03-21 | | 2. Per-Asset-Type Breakdown | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index a80cf7f..c7f6aa2 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,16 +5,16 @@ See: .planning/PROJECT.md (updated 2026-03-21) **Core value:** 提供可复用的盈亏统计方法,使平台运营能从用户和活动两个维度快速了解各类资产的收支状况 -**Current focus:** Phase 1 — Core P&L Functions +**Current focus:** Phase 2 — Per-Asset-Type Breakdown ## Current Position -Phase: 1 of 2 (Core P&L Functions) -Plan: 0 of TBD in current phase -Status: Ready to plan -Last activity: 2026-03-21 — Roadmap created, ready to begin Phase 1 planning +Phase: 1 of 2 (COMPLETE) → Phase 2 next +Plan: 4/4 in Phase 1 complete +Status: Phase 1 complete — ready to plan Phase 2 +Last activity: 2026-03-21 — Phase 1 executed: QueryUserProfitLoss + QueryActivityProfitLoss implemented, 22 tests passing -Progress: [░░░░░░░░░░] 0% +Progress: [█████░░░░░] 50% ## Performance Metrics diff --git a/internal/api/admin/admin.go b/internal/api/admin/admin.go index 4dfdab2..b090d02 100755 --- a/internal/api/admin/admin.go +++ b/internal/api/admin/admin.go @@ -9,6 +9,7 @@ import ( bannersvc "bindbox-game/internal/service/banner" channelsvc "bindbox-game/internal/service/channel" douyinsvc "bindbox-game/internal/service/douyin" + financesvc "bindbox-game/internal/service/finance" gamesvc "bindbox-game/internal/service/game" livestreamsvc "bindbox-game/internal/service/livestream" productsvc "bindbox-game/internal/service/product" @@ -39,6 +40,7 @@ type handler struct { douyinSvc douyinsvc.Service livestream livestreamsvc.Service synthesis synthesissvc.Service + financeSvc financesvc.Service // P&L service (read-only) } func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler { @@ -64,7 +66,8 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler snapshotSvc: snapshotSvc, rollbackSvc: rollbackSvc, douyinSvc: douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc), - livestream: livestreamsvc.New(logger, db, ticketSvc), // 传入ticketSvc + livestream: livestreamsvc.New(logger, db, ticketSvc), synthesis: synthesissvc.New(db), + financeSvc: financesvc.New(logger, db), } } diff --git a/internal/api/admin/dashboard_activity.go b/internal/api/admin/dashboard_activity.go index dd0e922..8fbd6ae 100755 --- a/internal/api/admin/dashboard_activity.go +++ b/internal/api/admin/dashboard_activity.go @@ -222,44 +222,26 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc { } } - // 4. 统计成本 (通过 user_inventory 关联 products 和 orders) - // 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2) - type costStat struct { - ActivityID int64 - TotalCost int64 - TotalCostBase int64 - AvgMultiplierX10 int64 + // 4. 从 finance.Service 获取成本(替换原有直接 SQL 成本查询) + // finance.Service 用 value_cents 作为单一真相源(D-09),无需 COALESCE fallback chain + financeParams := financesvc.ActivityProfitLossParams{ + ActivityIDs: activityIDs, } - var costStats []costStat - if err := db.Table(model.TableNameUserInventory). - Select(` - COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) as activity_id, - CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as total_cost, - SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0)) as total_cost_base, - CAST(COALESCE(AVG(GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 100), 10) AS SIGNED) as avg_multiplier_x10 - `). - Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id"). - Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id"). - Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id"). - Joins("LEFT JOIN products ON products.id = user_inventory.product_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("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) IN ?", activityIDs). - Where("user_inventory.status IN ?", []int{1, 3}). - Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%"). - // 兼容历史数据:部分老资产可能未写入 order_id,避免被 JOIN 条件整批过滤为0 - Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2). - Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)"). - Scan(&costStats).Error; err != nil { - h.logger.Error(fmt.Sprintf("GetActivityProfitLoss cost stats error: %v", err)) - } else { - for _, s := range costStats { - if item, ok := activityMap[s.ActivityID]; ok { - item.TotalCost = s.TotalCost - item.PrizeCostBase = s.TotalCostBase - item.PrizeCostFinal = s.TotalCost - item.PrizeCostMultiplier = s.AvgMultiplierX10 - } + financeResult, financeErr := h.financeSvc.QueryActivityProfitLoss(ctx.RequestContext(), financeParams) + if financeErr != nil { + h.logger.Error(fmt.Sprintf("GetActivityProfitLoss finance cost error: %v", financeErr)) + } + // 按 activity_id 建立 cost 索引 + financeCostMap := make(map[int64]int64) + if financeResult != nil { + for _, d := range financeResult.Details { + financeCostMap[d.ActivityID] = d.Cost + } + } + for actID, item := range activityMap { + if cost, ok := financeCostMap[actID]; ok { + item.TotalCost = cost + item.PrizeCostFinal = cost } } @@ -297,7 +279,8 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc { } // 6. 计算盈亏和比率 - // 公式: 盈亏 = 用户支出(普通单支付+优惠券 或 次卡价值) - 奖品成本(含道具卡倍率) + // 成本来自 finance.Service(value_cents 单一真相源 + 道具卡倍率) + // 收入来自原有 scan(保留 total_discount / total_game_pass_value 拆分字段) finalList := make([]activityProfitLossItem, 0, len(activities)) for _, a := range activities { item := activityMap[a.ID] diff --git a/internal/service/finance/query_activity.go b/internal/service/finance/query_activity.go new file mode 100644 index 0000000..476e0b0 --- /dev/null +++ b/internal/service/finance/query_activity.go @@ -0,0 +1,189 @@ +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 +} diff --git a/internal/service/finance/query_user.go b/internal/service/finance/query_user.go new file mode 100644 index 0000000..5321e18 --- /dev/null +++ b/internal/service/finance/query_user.go @@ -0,0 +1,201 @@ +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 +} diff --git a/internal/service/finance/service.go b/internal/service/finance/service.go new file mode 100644 index 0000000..3e12b06 --- /dev/null +++ b/internal/service/finance/service.go @@ -0,0 +1,39 @@ +package finance + +import ( + "context" + + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + "gorm.io/gorm" +) + +// Service defines the finance P&L query interface. +type Service interface { + QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) + QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) +} + +type service struct { + logger logger.CustomLogger + dbR *gorm.DB // read replica only — QUA-02: no writes in this package +} + +// New creates a new finance Service backed by the read-only DB replica. +// CRITICAL: only db.GetDbR() is called — never GetDbW() (QUA-02). +func New(l logger.CustomLogger, db mysql.Repo) Service { + return &service{ + logger: l, + dbR: db.GetDbR(), + } +} + +// QueryUserProfitLoss dispatches to the user-dimension implementation (Plan 02). +func (s *service) QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) { + return s.queryUser(ctx, params) +} + +// QueryActivityProfitLoss dispatches to the activity-dimension implementation (Plan 03). +func (s *service) QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) { + return s.queryActivity(ctx, params) +} diff --git a/internal/service/finance/service_test.go b/internal/service/finance/service_test.go new file mode 100644 index 0000000..952766e --- /dev/null +++ b/internal/service/finance/service_test.go @@ -0,0 +1,385 @@ +package finance + +import ( + "context" + "testing" + + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/model" + + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// newTestSvc creates an in-memory SQLite repo, creates all required tables, +// and returns (Service, *gorm.DB) for test use. +// NOTE: Uses manual CREATE TABLE instead of AutoMigrate to avoid CURRENT_TIMESTAMP(3) +// SQLite incompatibility present in the GORM model tags. +func newTestSvc(t *testing.T) (Service, *gorm.DB) { + t.Helper() + repo, err := mysql.NewSQLiteRepoForTest() + require.NoError(t, err) + db := repo.GetDbR() + + // Create tables manually — SQLite does not support CURRENT_TIMESTAMP(3) + // which is present in the GORM model default tags. + stmts := []string{ + `CREATE TABLE IF NOT EXISTS orders ( + id integer primary key, + created_at datetime, + updated_at datetime, + user_id integer not null default 0, + order_no text not null default '', + source_type integer not null default 1, + total_amount integer not null default 0, + discount_amount integer not null default 0, + points_amount integer not null default 0, + actual_amount integer not null default 0, + status integer not null default 1, + pay_preorder_id integer, + paid_at datetime, + cancelled_at datetime, + user_address_id integer, + is_consumed integer not null default 0, + points_ledger_id integer, + coupon_id integer, + item_card_id integer, + remark text, + ext_order_id text not null default '' + )`, + `CREATE TABLE IF NOT EXISTS user_inventory ( + id integer primary key, + created_at datetime, + updated_at datetime, + user_id integer not null default 0, + product_id integer, + value_cents integer not null default 0, + value_source integer not null default 0, + value_snapshot_at datetime, + order_id integer, + activity_id integer, + reward_id integer, + status integer not null default 1, + shipping_no text not null default '', + remark text + )`, + `CREATE TABLE IF NOT EXISTS user_points_ledger ( + id integer primary key, + created_at datetime, + user_id integer not null default 0, + action text not null default '', + points integer not null default 0, + ref_table text, + ref_id text, + remark text + )`, + `CREATE TABLE IF NOT EXISTS user_coupon_ledger ( + id integer primary key, + user_id integer not null default 0, + user_coupon_id integer not null default 0, + change_amount integer not null default 0, + balance_after integer not null default 0, + order_id integer, + action text not null default '', + created_at datetime + )`, + `CREATE TABLE IF NOT EXISTS activity_draw_logs (id integer primary key, order_id integer, issue_id integer, user_id integer)`, + `CREATE TABLE IF NOT EXISTS activity_issues (id integer primary key, activity_id integer not null)`, + `CREATE TABLE IF NOT EXISTS activities (id integer primary key, price_draw integer not null default 0)`, + `CREATE TABLE IF NOT EXISTS user_item_cards (id integer primary key, card_id integer)`, + `CREATE TABLE IF NOT EXISTS system_item_cards (id integer primary key, reward_multiplier_x1000 integer)`, + `CREATE TABLE IF NOT EXISTS system_configs (id integer primary key, config_key text, config_value text)`, + } + for _, stmt := range stmts { + require.NoError(t, db.Exec(stmt).Error) + } + + l, err := logger.NewCustomLogger(logger.WithOutputInConsole()) + require.NoError(t, err) + svc := New(l, repo) + return svc, db +} + +// --- Seed helpers --- + +func seedOrder(t *testing.T, db *gorm.DB, o model.Orders) { + t.Helper() + require.NoError(t, db.Create(&o).Error) +} + +func seedInventory(t *testing.T, db *gorm.DB, inv model.UserInventory) { + t.Helper() + require.NoError(t, db.Create(&inv).Error) +} + +func seedPointsLedger(t *testing.T, db *gorm.DB, row model.UserPointsLedger) { + t.Helper() + require.NoError(t, db.Create(&row).Error) +} + +func seedCouponLedger(t *testing.T, db *gorm.DB, row model.UserCouponLedger) { + t.Helper() + require.NoError(t, db.Create(&row).Error) +} + +// seedActivitySetup creates minimal activity + issue + draw_log for JOIN tests. +func seedActivitySetup(t *testing.T, db *gorm.DB, activityID, issueID, orderID, userID int64, priceDraw int64) { + t.Helper() + require.NoError(t, db.Exec("INSERT OR IGNORE INTO activities (id, price_draw) VALUES (?, ?)", activityID, priceDraw).Error) + require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_issues (id, activity_id) VALUES (?, ?)", issueID, activityID).Error) + require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_draw_logs (id, order_id, issue_id, user_id) VALUES (?, ?, ?, ?)", orderID*100+issueID, orderID, issueID, userID).Error) +} + +// --- Plan 01 contract tests --- + +func TestAssetTypeConstants(t *testing.T) { + require.Equal(t, AssetType(0), AssetTypeAll) + require.Equal(t, AssetType(1), AssetTypePoints) + require.Equal(t, AssetType(2), AssetTypeCoupon) + require.Equal(t, AssetType(3), AssetTypeItemCard) + require.Equal(t, AssetType(4), AssetTypeProduct) + require.Equal(t, AssetType(5), AssetTypeFragment) +} + +func TestNew_ReturnsService(t *testing.T) { + svc, _ := newTestSvc(t) + require.NotNil(t, svc) +} + +func TestQueryUserProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) { + svc, _ := newTestSvc(t) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{}) + require.NoError(t, err) + _ = result +} + +func TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) { + svc, _ := newTestSvc(t) + result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{}) + require.NoError(t, err) + _ = result +} + +// --- Plan 02 QueryUserProfitLoss integration tests --- + +func TestQueryUserProfitLoss_CashOrder(t *testing.T) { + svc, db := newTestSvc(t) + seedOrder(t, db, model.Orders{ + ID: 1, UserID: 101, Status: 2, + SourceType: 2, OrderNo: "O20260321001", + ActualAmount: 800, DiscountAmount: 200, + }) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{101}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(1000), result.TotalRevenue, "cash revenue = actual + discount") +} + +func TestQueryUserProfitLoss_RefundedOrderExcluded(t *testing.T) { + svc, db := newTestSvc(t) + seedOrder(t, db, model.Orders{ + ID: 2, UserID: 102, Status: 4, // refunded + SourceType: 2, OrderNo: "O20260321002", + ActualAmount: 1000, DiscountAmount: 0, + }) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{102}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue") +} + +func TestQueryUserProfitLoss_VoidedInventoryExcluded(t *testing.T) { + svc, db := newTestSvc(t) + seedInventory(t, db, model.UserInventory{ + ID: 1, UserID: 103, Status: 2, // voided status + ValueCents: 5000, OrderID: 0, + }) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{103}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(0), result.TotalCost, "voided inventory (status=2) must not contribute cost") +} + +func TestQueryUserProfitLoss_RemarkVoidExcluded(t *testing.T) { + svc, db := newTestSvc(t) + seedInventory(t, db, model.UserInventory{ + ID: 2, UserID: 104, Status: 1, // valid status + ValueCents: 3000, OrderID: 0, + Remark: "void_20260101", + }) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{104}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(0), result.TotalCost, "inventory with remark containing 'void' must not contribute cost") +} + +func TestQueryUserProfitLoss_LegacyZeroOrderID(t *testing.T) { + svc, db := newTestSvc(t) + seedInventory(t, db, model.UserInventory{ + ID: 3, UserID: 105, Status: 1, + ValueCents: 2000, OrderID: 0, + Remark: "", + }) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{105}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(2000), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)") +} + +func TestQueryUserProfitLoss_AllUsers(t *testing.T) { + svc, db := newTestSvc(t) + seedOrder(t, db, model.Orders{ID: 10, UserID: 201, Status: 2, SourceType: 2, OrderNo: "O001", ActualAmount: 100}) + seedOrder(t, db, model.Orders{ID: 11, UserID: 202, Status: 2, SourceType: 2, OrderNo: "O002", ActualAmount: 200}) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{}) + require.NoError(t, err) + require.NotNil(t, result) + userIDs := make(map[int64]bool) + for _, d := range result.Details { + userIDs[d.UserID] = true + } + require.True(t, userIDs[201], "user 201 must be in results") + require.True(t, userIDs[202], "user 202 must be in results") +} + +func TestQueryUserProfitLoss_FilterByUserID(t *testing.T) { + svc, db := newTestSvc(t) + seedOrder(t, db, model.Orders{ID: 20, UserID: 301, Status: 2, SourceType: 2, OrderNo: "O003", ActualAmount: 500}) + seedOrder(t, db, model.Orders{ID: 21, UserID: 302, Status: 2, SourceType: 2, OrderNo: "O004", ActualAmount: 600}) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{301}}) + require.NoError(t, err) + require.NotNil(t, result) + for _, d := range result.Details { + require.Equal(t, int64(301), d.UserID, "only user 301 should appear") + } +} + +func TestQueryUserProfitLoss_ProfitCalculation(t *testing.T) { + svc, db := newTestSvc(t) + seedOrder(t, db, model.Orders{ID: 30, UserID: 401, Status: 2, SourceType: 2, OrderNo: "O005", ActualAmount: 1000, DiscountAmount: 200}) + seedInventory(t, db, model.UserInventory{ID: 10, UserID: 401, Status: 1, ValueCents: 800, OrderID: 30, Remark: ""}) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{401}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(1200), result.TotalRevenue) + require.Equal(t, int64(800), result.TotalCost) + require.Equal(t, int64(400), result.TotalProfit, "profit = revenue - cost") +} + +func TestQueryUserProfitLoss_ResultShape(t *testing.T) { + svc, _ := newTestSvc(t) + result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{}) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Details, "Details must be non-nil slice") + require.NotNil(t, result.Breakdown, "Breakdown must be non-nil slice (empty for Phase 1)") +} + +// --- Plan 03 QueryActivityProfitLoss integration tests --- + +func TestQueryActivityProfitLoss_CashOrderRevenue(t *testing.T) { + svc, db := newTestSvc(t) + seedOrder(t, db, model.Orders{ + ID: 50, UserID: 501, Status: 2, + SourceType: 2, OrderNo: "A001", + ActualAmount: 600, DiscountAmount: 150, + }) + seedActivitySetup(t, db, 1001, 2001, 50, 501, 100) + result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1001}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(750), result.TotalRevenue, "cash revenue = actual(600) + discount(150)") +} + +func TestQueryActivityProfitLoss_RefundedOrderExcluded(t *testing.T) { + svc, db := newTestSvc(t) + seedOrder(t, db, model.Orders{ + ID: 51, UserID: 502, Status: 4, // refunded + SourceType: 2, OrderNo: "A002", + ActualAmount: 800, DiscountAmount: 0, + }) + seedActivitySetup(t, db, 1002, 2002, 51, 502, 100) + result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1002}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue") +} + +func TestQueryActivityProfitLoss_VoidedInventoryExcluded(t *testing.T) { + svc, db := newTestSvc(t) + seedInventory(t, db, model.UserInventory{ + ID: 20, UserID: 503, ActivityID: 1003, + Status: 2, // voided + ValueCents: 4000, OrderID: 0, + }) + result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1003}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(0), result.TotalCost, "voided inventory must not contribute cost") +} + +func TestQueryActivityProfitLoss_LegacyZeroOrderID(t *testing.T) { + svc, db := newTestSvc(t) + seedInventory(t, db, model.UserInventory{ + ID: 21, UserID: 504, ActivityID: 1004, + Status: 1, ValueCents: 3500, OrderID: 0, + Remark: "", + }) + result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1004}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(3500), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)") +} + +func TestQueryActivityProfitLoss_AllActivities(t *testing.T) { + svc, db := newTestSvc(t) + seedOrder(t, db, model.Orders{ID: 60, UserID: 601, Status: 2, SourceType: 2, OrderNo: "A010", ActualAmount: 100}) + seedOrder(t, db, model.Orders{ID: 61, UserID: 602, Status: 2, SourceType: 2, OrderNo: "A011", ActualAmount: 200}) + seedActivitySetup(t, db, 2001, 3001, 60, 601, 50) + seedActivitySetup(t, db, 2002, 3002, 61, 602, 50) + result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{}) + require.NoError(t, err) + require.NotNil(t, result) + actIDs := make(map[int64]bool) + for _, d := range result.Details { + actIDs[d.ActivityID] = true + } + require.True(t, actIDs[2001], "activity 2001 must be in results") + require.True(t, actIDs[2002], "activity 2002 must be in results") +} + +func TestQueryActivityProfitLoss_FilterByActivityID(t *testing.T) { + svc, db := newTestSvc(t) + seedOrder(t, db, model.Orders{ID: 70, UserID: 701, Status: 2, SourceType: 2, OrderNo: "A020", ActualAmount: 300}) + seedOrder(t, db, model.Orders{ID: 71, UserID: 702, Status: 2, SourceType: 2, OrderNo: "A021", ActualAmount: 400}) + seedActivitySetup(t, db, 3001, 4001, 70, 701, 50) + seedActivitySetup(t, db, 3002, 4002, 71, 702, 50) + result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{3001}}) + require.NoError(t, err) + require.NotNil(t, result) + for _, d := range result.Details { + require.Equal(t, int64(3001), d.ActivityID, "only activity 3001 should appear") + } +} + +func TestQueryActivityProfitLoss_ProfitCalculation(t *testing.T) { + svc, db := newTestSvc(t) + seedOrder(t, db, model.Orders{ID: 80, UserID: 801, Status: 2, SourceType: 2, OrderNo: "A030", ActualAmount: 2000, DiscountAmount: 500}) + seedActivitySetup(t, db, 4001, 5001, 80, 801, 100) + seedInventory(t, db, model.UserInventory{ID: 30, UserID: 801, ActivityID: 4001, Status: 1, ValueCents: 1200, OrderID: 80}) + result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{4001}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(2500), result.TotalRevenue, "revenue = actual(2000) + discount(500)") + require.Equal(t, int64(1200), result.TotalCost) + require.Equal(t, int64(1300), result.TotalProfit, "profit = 2500 - 1200") +} + +func TestQueryActivityProfitLoss_ResultShape(t *testing.T) { + svc, _ := newTestSvc(t) + result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{}) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Details, "Details must be non-nil slice") + require.NotNil(t, result.Breakdown, "Breakdown must be non-nil empty slice") +} diff --git a/internal/service/finance/types.go b/internal/service/finance/types.go new file mode 100644 index 0000000..cc667b3 --- /dev/null +++ b/internal/service/finance/types.go @@ -0,0 +1,51 @@ +package finance + +import "time" + +// AssetType represents the type of asset in P&L calculations. +type AssetType int + +const ( + AssetTypeAll AssetType = 0 // zero value = all types (DIM-04) + AssetTypePoints AssetType = 1 + AssetTypeCoupon AssetType = 2 + AssetTypeItemCard AssetType = 3 + AssetTypeProduct AssetType = 4 + AssetTypeFragment AssetType = 5 +) + +// UserProfitLossParams — all fields optional (D-07) +type UserProfitLossParams struct { + UserIDs []int64 // empty = all users (DIM-01) + AssetType AssetType // 0 = all types (DIM-04) + StartTime *time.Time // nil = no lower bound (DIM-03) + EndTime *time.Time // nil = no upper bound (DIM-03) +} + +// ActivityProfitLossParams — all fields optional (D-07) +type ActivityProfitLossParams struct { + ActivityIDs []int64 // empty = all activities (DIM-02) + AssetType AssetType // 0 = all types (DIM-04) + StartTime *time.Time // nil = no lower bound (DIM-03) + EndTime *time.Time // nil = no upper bound (DIM-03) +} + +// ProfitLossDetail — per-user or per-activity row (D-06) +type ProfitLossDetail struct { + UserID int64 // populated for user dimension queries + ActivityID int64 // populated for activity dimension queries + Revenue int64 // fen (RET-03: int64 only, no float64 for monetary) + Cost int64 // fen + Profit int64 // fen + ProfitRate float64 // ratio; only float64 field for monetary concept +} + +// ProfitLossResult — aggregated P&L result (RET-01) +type ProfitLossResult struct { + TotalRevenue int64 // fen + TotalCost int64 // fen + TotalProfit int64 // fen + ProfitRate float64 // ratio + Details []ProfitLossDetail // per-user or per-activity breakdowns (D-06) + Breakdown []interface{} // Phase 2: per-asset-type breakdown (empty for Phase 1) +}