diff --git a/backend/internal/modules/invitation/admin_filters_test.go b/backend/internal/modules/invitation/admin_filters_test.go index 384041a..9d8c3b3 100644 --- a/backend/internal/modules/invitation/admin_filters_test.go +++ b/backend/internal/modules/invitation/admin_filters_test.go @@ -6,6 +6,8 @@ import ( "time" "hfb_sys/backend/internal/model" + + "gorm.io/gorm" ) func TestListAdminInvitationsFiltersPhoneAndInviteCode(t *testing.T) { @@ -74,6 +76,244 @@ func TestListAdminInvitationsFiltersPhoneAndInviteCode(t *testing.T) { } } +// seedRebateSummaryFixture 构造覆盖订单/提号、买家/卖家、三个周期阶段与跨月时间的流水。 +func seedRebateSummaryFixture(t *testing.T, db *gorm.DB) []model.User { + t.Helper() + + users := []model.User{ + {Phone: "13800001001", Nickname: "邀请人A", Status: "active", RealnameStatus: "verified"}, + {Phone: "13800001002", Nickname: "邀请人B", Status: "active", RealnameStatus: "verified"}, + {Phone: "13900001001", Nickname: "被邀请人C", Status: "active", RealnameStatus: "verified"}, + {Phone: "13900001002", Nickname: "被邀请人D", Status: "active", RealnameStatus: "verified"}, + } + for i := range users { + if err := db.Create(&users[i]).Error; err != nil { + t.Fatalf("创建测试用户失败: %v", err) + } + } + + ledgers := []model.InvitationRebateLedger{ + { + OrderID: 3001, + OrderNo: "ORDER-2026-01-10-A", + Source: "rental_order", + InviterID: users[0].ID, + InviteeID: users[2].ID, + TradeRole: "buyer", + RateApplied: 0.6, + CycleStage: CycleFirstMonth, + RebateAmountCent: 100, + Status: "settled", + SettledAt: time.Date(2026, 1, 10, 10, 0, 0, 0, time.Local), + }, + { + OrderID: 3002, + OrderNo: "ORDER-2026-01-10-B", + Source: "rental_order", + InviterID: users[0].ID, + InviteeID: users[3].ID, + TradeRole: "seller", + RateApplied: 0.3, + CycleStage: CycleSecondMonth, + RebateAmountCent: 200, + Status: "settled", + SettledAt: time.Date(2026, 1, 10, 15, 0, 0, 0, time.Local), + }, + { + OrderID: 3003, + OrderNo: "PICKUP-2026-02-05", + Source: "pickup", + InviterID: users[1].ID, + InviteeID: users[2].ID, + TradeRole: "seller", + RateApplied: 0.15, + CycleStage: CycleSubsequent, + RebateAmountCent: 300, + Status: "settled", + SettledAt: time.Date(2026, 2, 5, 9, 30, 0, 0, time.Local), + }, + { + OrderID: 3004, + OrderNo: "ORDER-2026-03-01", + Source: "rental_order", + InviterID: users[0].ID, + InviteeID: users[3].ID, + TradeRole: "buyer", + RateApplied: 0.6, + CycleStage: CycleFirstMonth, + RebateAmountCent: 400, + Status: "settled", + SettledAt: time.Date(2026, 3, 1, 18, 0, 0, 0, time.Local), + }, + } + for i := range ledgers { + if err := db.Create(&ledgers[i]).Error; err != nil { + t.Fatalf("创建返利流水失败: %v", err) + } + } + return users +} + +func TestListAdminRebateLedgersFiltersSourceAndDateRange(t *testing.T) { + db := setupTestDB(t) + repo := NewRepository(db) + ctx := context.Background() + seedRebateSummaryFixture(t, db) + + cases := []struct { + name string + query AdminRebateLedgerQuery + want int64 + }{ + {name: "来源筛选取号", query: AdminRebateLedgerQuery{Source: "pickup"}, want: 1}, + {name: "来源筛选订单", query: AdminRebateLedgerQuery{Source: "rental_order"}, want: 3}, + {name: "交易端筛选买家", query: AdminRebateLedgerQuery{TradeRole: "buyer"}, want: 2}, + { + name: "时间范围筛选单月", + query: AdminRebateLedgerQuery{StartDate: "2026-01-01", EndDate: "2026-01-31"}, + want: 2, + }, + { + name: "时间范围筛选起始日", + query: AdminRebateLedgerQuery{StartDate: "2026-02-01"}, + want: 2, + }, + { + name: "时间范围筛选结束日", + query: AdminRebateLedgerQuery{EndDate: "2026-01-31"}, + want: 2, + }, + { + name: "来源与时间范围组合", + query: AdminRebateLedgerQuery{ + Source: "rental_order", + StartDate: "2026-02-01", + EndDate: "2026-02-28", + }, + want: 0, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.query.Page = 1 + tc.query.PageSize = 20 + _, total, err := repo.ListAdminRebateLedgers(ctx, tc.query) + if err != nil { + t.Fatalf("查询返利流水失败: %v", err) + } + if total != tc.want { + t.Fatalf("查询结果数量 = %d, want %d", total, tc.want) + } + }) + } +} + +func TestAdminRebateSummaryAggregatesAndBreakdowns(t *testing.T) { + db := setupTestDB(t) + repo := NewRepository(db) + ctx := context.Background() + users := seedRebateSummaryFixture(t, db) + + summary, err := repo.AdminRebateSummary(ctx, AdminRebateLedgerQuery{}) + if err != nil { + t.Fatalf("查询返利统计失败: %v", err) + } + + if summary.TotalRebateCent != 1000 { + t.Fatalf("返利总额 = %d, want 1000", summary.TotalRebateCent) + } + if summary.LedgerCount != 4 { + t.Fatalf("流水笔数 = %d, want 4", summary.LedgerCount) + } + if summary.InviterCount != 2 { + t.Fatalf("邀请人数 = %d, want 2", summary.InviterCount) + } + if summary.InviteeCount != 2 { + t.Fatalf("被邀请人数 = %d, want 2", summary.InviteeCount) + } + if summary.AvgRebateCent != 250 { + t.Fatalf("平均返利 = %d, want 250", summary.AvgRebateCent) + } + if summary.OrderRebateCent != 700 || summary.OrderCount != 3 { + t.Fatalf("订单返利 = %d/%d 笔, want 700/3", summary.OrderRebateCent, summary.OrderCount) + } + if summary.PickupRebateCent != 300 || summary.PickupCount != 1 { + t.Fatalf("提号返利 = %d/%d 笔, want 300/1", summary.PickupRebateCent, summary.PickupCount) + } + if summary.BuyerRebateCent != 500 || summary.BuyerCount != 2 { + t.Fatalf("买家端返利 = %d/%d 笔, want 500/2", summary.BuyerRebateCent, summary.BuyerCount) + } + if summary.SellerRebateCent != 500 || summary.SellerCount != 2 { + t.Fatalf("卖家端返利 = %d/%d 笔, want 500/2", summary.SellerRebateCent, summary.SellerCount) + } + if summary.FirstMonthCent != 500 || summary.SecondMonthCent != 200 || summary.SubsequentCent != 300 { + t.Fatalf( + "周期返利 = %d/%d/%d, want 500/200/300", + summary.FirstMonthCent, + summary.SecondMonthCent, + summary.SubsequentCent, + ) + } + + if len(summary.Daily) != 3 { + t.Fatalf("每日统计行数 = %d, want 3", len(summary.Daily)) + } + daily := make(map[string]AdminRebateDailyStat, len(summary.Daily)) + for _, row := range summary.Daily { + daily[row.Date] = row + } + january, ok := daily["2026-01-10"] + if !ok { + t.Fatalf("缺少 2026-01-10 的每日统计: %+v", summary.Daily) + } + if january.LedgerCount != 2 || january.RebateAmountCent != 300 || + january.OrderAmountCent != 300 || january.PickupAmountCent != 0 { + t.Fatalf("2026-01-10 统计错误: %+v", january) + } + if got := daily["2026-02-05"]; got.PickupAmountCent != 300 || got.OrderAmountCent != 0 { + t.Fatalf("2026-02-05 统计错误: %+v", got) + } + if got := daily["2026-03-01"]; got.RebateAmountCent != 400 { + t.Fatalf("2026-03-01 统计错误: %+v", got) + } + + if len(summary.TopInviters) != 2 { + t.Fatalf("Top 邀请人数 = %d, want 2", len(summary.TopInviters)) + } + top := summary.TopInviters[0] + if top.InviterID != users[0].ID || top.RebateAmountCent != 700 || top.LedgerCount != 3 { + t.Fatalf("Top 邀请人统计错误: %+v", top) + } + if top.InviterPhone != users[0].Phone || top.InviterNickname != users[0].Nickname { + t.Fatalf("Top 邀请人信息错误: %+v", top) + } + + filtered, err := repo.AdminRebateSummary(ctx, AdminRebateLedgerQuery{ + StartDate: "2026-01-01", + EndDate: "2026-01-31", + }) + if err != nil { + t.Fatalf("按时间查询返利统计失败: %v", err) + } + if filtered.TotalRebateCent != 300 || filtered.LedgerCount != 2 || len(filtered.Daily) != 1 { + t.Fatalf( + "时间筛选统计错误: total=%d count=%d daily=%d", + filtered.TotalRebateCent, + filtered.LedgerCount, + len(filtered.Daily), + ) + } + + pickupOnly, err := repo.AdminRebateSummary(ctx, AdminRebateLedgerQuery{Source: "pickup"}) + if err != nil { + t.Fatalf("按来源查询返利统计失败: %v", err) + } + if pickupOnly.TotalRebateCent != 300 || pickupOnly.LedgerCount != 1 || + pickupOnly.OrderCount != 0 || pickupOnly.PickupCount != 1 { + t.Fatalf("来源筛选统计错误: %+v", pickupOnly) + } +} + func TestListAdminInvitationsIncludesInviterAggregateStats(t *testing.T) { db := setupTestDB(t) repo := NewRepository(db) diff --git a/backend/internal/modules/invitation/dto.go b/backend/internal/modules/invitation/dto.go index 0c6056c..590de92 100644 --- a/backend/internal/modules/invitation/dto.go +++ b/backend/internal/modules/invitation/dto.go @@ -98,8 +98,49 @@ type AdminRebateLedgerQuery struct { InviterPhone string `form:"inviter_phone"` InviteePhone string `form:"invitee_phone"` TradeRole string `form:"trade_role"` + Source string `form:"source"` + StartDate string `form:"start_date"` + EndDate string `form:"end_date"` DistributionSiteID *uint64 `form:"distribution_site_id"` // InviterID / InviteeID 供用户管理「明细」按用户精确筛选。 InviterID uint64 `form:"inviter_id"` InviteeID uint64 `form:"invitee_id"` } + +// AdminRebateSummaryDTO 返利统计页汇总数据,所有金额单位为分。 +type AdminRebateSummaryDTO struct { + TotalRebateCent int64 `json:"total_rebate_cent"` + LedgerCount int64 `json:"ledger_count"` + InviterCount int64 `json:"inviter_count"` + InviteeCount int64 `json:"invitee_count"` + AvgRebateCent int64 `json:"avg_rebate_cent"` + OrderRebateCent int64 `json:"order_rebate_cent"` + OrderCount int64 `json:"order_count"` + PickupRebateCent int64 `json:"pickup_rebate_cent"` + PickupCount int64 `json:"pickup_count"` + BuyerRebateCent int64 `json:"buyer_rebate_cent"` + BuyerCount int64 `json:"buyer_count"` + SellerRebateCent int64 `json:"seller_rebate_cent"` + SellerCount int64 `json:"seller_count"` + FirstMonthCent int64 `json:"first_month_cent"` + SecondMonthCent int64 `json:"second_month_cent"` + SubsequentCent int64 `json:"subsequent_cent"` + Daily []AdminRebateDailyStat `json:"daily"` + TopInviters []AdminRebateInviterStat `json:"top_inviters"` +} + +type AdminRebateDailyStat struct { + Date string `json:"date"` + LedgerCount int64 `json:"ledger_count"` + RebateAmountCent int64 `json:"rebate_amount_cent"` + OrderAmountCent int64 `json:"order_amount_cent"` + PickupAmountCent int64 `json:"pickup_amount_cent"` +} + +type AdminRebateInviterStat struct { + InviterID uint64 `json:"inviter_id"` + InviterPhone string `json:"inviter_phone"` + InviterNickname string `json:"inviter_nickname"` + LedgerCount int64 `json:"ledger_count"` + RebateAmountCent int64 `json:"rebate_amount_cent"` +} diff --git a/backend/internal/modules/invitation/handler.go b/backend/internal/modules/invitation/handler.go index 6caf3dc..ee68286 100644 --- a/backend/internal/modules/invitation/handler.go +++ b/backend/internal/modules/invitation/handler.go @@ -170,6 +170,21 @@ func (h *Handler) AdminRebates(c *gin.Context) { }) } +// AdminRebateSummary 管理员查看返利统计汇总 +func (h *Handler) AdminRebateSummary(c *gin.Context) { + var query AdminRebateLedgerQuery + if err := c.ShouldBindQuery(&query); err != nil { + response.BadRequest(c, "查询参数不正确") + return + } + summary, err := h.service.AdminRebateSummary(c.Request.Context(), query) + if err != nil { + writeInvitationError(c, err) + return + } + response.OK(c, summary) +} + func currentUserID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextUserID) if !ok { diff --git a/backend/internal/modules/invitation/repository.go b/backend/internal/modules/invitation/repository.go index 358addb..d1bb9c7 100644 --- a/backend/internal/modules/invitation/repository.go +++ b/backend/internal/modules/invitation/repository.go @@ -998,17 +998,8 @@ func (r *Repository) ListAdminInvitations(ctx context.Context, query AdminInvita return list, total, nil } -// ListAdminRebateLedgers 管理后台查询返利流水 -func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminRebateLedgerQuery) ([]map[string]any, int64, error) { - if r == nil || r.db == nil { - return nil, 0, ErrDependencyUnavailable - } - if query.Page < 1 { - query.Page = 1 - } - if query.PageSize < 1 || query.PageSize > 100 { - query.PageSize = 20 - } +// adminRebateLedgerBase 构造后台返利流水/统计数据共用的筛选范围。 +func (r *Repository) adminRebateLedgerBase(ctx context.Context, query AdminRebateLedgerQuery) *gorm.DB { db := r.db.WithContext(ctx).Table("invitation_rebate_ledgers irl"). Joins("LEFT JOIN users inviter ON inviter.id = irl.inviter_id"). Joins("LEFT JOIN users invitee ON invitee.id = irl.invitee_id") @@ -1025,6 +1016,15 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba if t := strings.TrimSpace(query.TradeRole); t != "" { db = db.Where("irl.trade_role = ?", t) } + if s := strings.TrimSpace(query.Source); s != "" { + db = db.Where("irl.source = ?", s) + } + if start, ok := parseAdminRebateDate(query.StartDate); ok { + db = db.Where("irl.settled_at >= ?", start) + } + if end, ok := parseAdminRebateDate(query.EndDate); ok { + db = db.Where("irl.settled_at < ?", end.AddDate(0, 0, 1)) + } if query.DistributionSiteID != nil { db = db.Where("irl.distribution_site_id = ?", *query.DistributionSiteID) } @@ -1034,6 +1034,34 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba if query.InviteeID > 0 { db = db.Where("irl.invitee_id = ?", query.InviteeID) } + return db +} + +func parseAdminRebateDate(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + for _, layout := range []string{"2006-01-02", "2006-01-02 15:04:05", time.RFC3339} { + if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +// ListAdminRebateLedgers 管理后台查询返利流水 +func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminRebateLedgerQuery) ([]map[string]any, int64, error) { + if r == nil || r.db == nil { + return nil, 0, ErrDependencyUnavailable + } + if query.Page < 1 { + query.Page = 1 + } + if query.PageSize < 1 || query.PageSize > 100 { + query.PageSize = 20 + } + db := r.adminRebateLedgerBase(ctx, query) var total int64 if err := db.Count(&total).Error; err != nil { @@ -1095,6 +1123,141 @@ func (r *Repository) ListAdminRebateLedgers(ctx context.Context, query AdminReba return list, total, nil } +// AdminRebateSummary 管理后台返利统计分析,沿用流水页的筛选条件。 +func (r *Repository) AdminRebateSummary(ctx context.Context, query AdminRebateLedgerQuery) (*AdminRebateSummaryDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + + type aggregateRow struct { + TotalRebateCent int64 `gorm:"column:total_rebate_cent"` + LedgerCount int64 `gorm:"column:ledger_count"` + InviterCount int64 `gorm:"column:inviter_count"` + InviteeCount int64 `gorm:"column:invitee_count"` + OrderRebateCent int64 `gorm:"column:order_rebate_cent"` + OrderCount int64 `gorm:"column:order_count"` + PickupRebateCent int64 `gorm:"column:pickup_rebate_cent"` + PickupCount int64 `gorm:"column:pickup_count"` + BuyerRebateCent int64 `gorm:"column:buyer_rebate_cent"` + BuyerCount int64 `gorm:"column:buyer_count"` + SellerRebateCent int64 `gorm:"column:seller_rebate_cent"` + SellerCount int64 `gorm:"column:seller_count"` + FirstMonthCent int64 `gorm:"column:first_month_cent"` + SecondMonthCent int64 `gorm:"column:second_month_cent"` + SubsequentCent int64 `gorm:"column:subsequent_cent"` + } + var aggregate aggregateRow + err := r.adminRebateLedgerBase(ctx, query).Select(` + COALESCE(SUM(irl.rebate_amount_cent), 0) AS total_rebate_cent, + COUNT(*) AS ledger_count, + COUNT(DISTINCT irl.inviter_id) AS inviter_count, + COUNT(DISTINCT irl.invitee_id) AS invitee_count, + COALESCE(SUM(CASE WHEN irl.source = 'rental_order' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS order_rebate_cent, + COALESCE(SUM(CASE WHEN irl.source = 'rental_order' THEN 1 ELSE 0 END), 0) AS order_count, + COALESCE(SUM(CASE WHEN irl.source = 'pickup' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS pickup_rebate_cent, + COALESCE(SUM(CASE WHEN irl.source = 'pickup' THEN 1 ELSE 0 END), 0) AS pickup_count, + COALESCE(SUM(CASE WHEN irl.trade_role = 'buyer' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS buyer_rebate_cent, + COALESCE(SUM(CASE WHEN irl.trade_role = 'buyer' THEN 1 ELSE 0 END), 0) AS buyer_count, + COALESCE(SUM(CASE WHEN irl.trade_role = 'seller' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS seller_rebate_cent, + COALESCE(SUM(CASE WHEN irl.trade_role = 'seller' THEN 1 ELSE 0 END), 0) AS seller_count, + COALESCE(SUM(CASE WHEN irl.cycle_stage = ? THEN irl.rebate_amount_cent ELSE 0 END), 0) AS first_month_cent, + COALESCE(SUM(CASE WHEN irl.cycle_stage = ? THEN irl.rebate_amount_cent ELSE 0 END), 0) AS second_month_cent, + COALESCE(SUM(CASE WHEN irl.cycle_stage = ? THEN irl.rebate_amount_cent ELSE 0 END), 0) AS subsequent_cent + `, CycleFirstMonth, CycleSecondMonth, CycleSubsequent).Scan(&aggregate).Error + if err != nil { + return nil, err + } + + result := &AdminRebateSummaryDTO{ + TotalRebateCent: aggregate.TotalRebateCent, + LedgerCount: aggregate.LedgerCount, + InviterCount: aggregate.InviterCount, + InviteeCount: aggregate.InviteeCount, + OrderRebateCent: aggregate.OrderRebateCent, + OrderCount: aggregate.OrderCount, + PickupRebateCent: aggregate.PickupRebateCent, + PickupCount: aggregate.PickupCount, + BuyerRebateCent: aggregate.BuyerRebateCent, + BuyerCount: aggregate.BuyerCount, + SellerRebateCent: aggregate.SellerRebateCent, + SellerCount: aggregate.SellerCount, + FirstMonthCent: aggregate.FirstMonthCent, + SecondMonthCent: aggregate.SecondMonthCent, + SubsequentCent: aggregate.SubsequentCent, + Daily: make([]AdminRebateDailyStat, 0), + TopInviters: make([]AdminRebateInviterStat, 0), + } + if result.LedgerCount > 0 { + result.AvgRebateCent = result.TotalRebateCent / result.LedgerCount + } + + type dailyRow struct { + Date string `gorm:"column:date"` + LedgerCount int64 `gorm:"column:ledger_count"` + RebateAmountCent int64 `gorm:"column:rebate_amount_cent"` + OrderAmountCent int64 `gorm:"column:order_amount_cent"` + PickupAmountCent int64 `gorm:"column:pickup_amount_cent"` + } + var daily []dailyRow + err = r.adminRebateLedgerBase(ctx, query). + Select(` + DATE_FORMAT(irl.settled_at, '%Y-%m-%d') AS date, + COUNT(*) AS ledger_count, + COALESCE(SUM(irl.rebate_amount_cent), 0) AS rebate_amount_cent, + COALESCE(SUM(CASE WHEN irl.source = 'rental_order' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS order_amount_cent, + COALESCE(SUM(CASE WHEN irl.source = 'pickup' THEN irl.rebate_amount_cent ELSE 0 END), 0) AS pickup_amount_cent + `). + Group("DATE_FORMAT(irl.settled_at, '%Y-%m-%d')"). + Order("DATE_FORMAT(irl.settled_at, '%Y-%m-%d') DESC"). + Scan(&daily).Error + if err != nil { + return nil, err + } + for _, row := range daily { + result.Daily = append(result.Daily, AdminRebateDailyStat{ + Date: row.Date, + LedgerCount: row.LedgerCount, + RebateAmountCent: row.RebateAmountCent, + OrderAmountCent: row.OrderAmountCent, + PickupAmountCent: row.PickupAmountCent, + }) + } + + type topInviterRow struct { + InviterID uint64 `gorm:"column:inviter_id"` + InviterPhone string `gorm:"column:inviter_phone"` + InviterNickname string `gorm:"column:inviter_nickname"` + LedgerCount int64 `gorm:"column:ledger_count"` + RebateAmountCent int64 `gorm:"column:rebate_amount_cent"` + } + var top []topInviterRow + err = r.adminRebateLedgerBase(ctx, query). + Select(` + irl.inviter_id, + COALESCE(inviter.phone, '') AS inviter_phone, + COALESCE(inviter.nickname, '') AS inviter_nickname, + COUNT(*) AS ledger_count, + COALESCE(SUM(irl.rebate_amount_cent), 0) AS rebate_amount_cent + `). + Group("irl.inviter_id, inviter.phone, inviter.nickname"). + Order("rebate_amount_cent DESC"). + Limit(10). + Scan(&top).Error + if err != nil { + return nil, err + } + for _, row := range top { + result.TopInviters = append(result.TopInviters, AdminRebateInviterStat{ + InviterID: row.InviterID, + InviterPhone: row.InviterPhone, + InviterNickname: row.InviterNickname, + LedgerCount: row.LedgerCount, + RebateAmountCent: row.RebateAmountCent, + }) + } + return result, nil +} + // ProcessOrderInvitationRebateTx 便捷事务入口,供订单结账完成终态时调用 func ProcessOrderInvitationRebateTx(tx *gorm.DB, orderID uint64) error { return ProcessOrderInvitationRebateWithOptionsTx(tx, orderID, OrderRebateOptions{}) diff --git a/backend/internal/modules/invitation/service.go b/backend/internal/modules/invitation/service.go index f35713e..2412019 100644 --- a/backend/internal/modules/invitation/service.go +++ b/backend/internal/modules/invitation/service.go @@ -107,3 +107,10 @@ func (s *Service) ListAdminRebateLedgers(ctx context.Context, query AdminRebateL } return s.repo.ListAdminRebateLedgers(ctx, query) } + +func (s *Service) AdminRebateSummary(ctx context.Context, query AdminRebateLedgerQuery) (*AdminRebateSummaryDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.AdminRebateSummary(ctx, query) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a3a0002..e665260 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -912,6 +912,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/invitations/configs", requirePerm("distribution:site_view"), invitationHandler.AdminGetConfig) adminRoutes.PUT("/invitations/configs", requirePerm("distribution:site_manage"), invitationHandler.AdminUpdateConfig) adminRoutes.GET("/invitations", requirePerm("distribution:site_view"), invitationHandler.AdminInvitations) + adminRoutes.GET("/invitations/rebates/summary", requirePerm("distribution:settlement_view"), invitationHandler.AdminRebateSummary) adminRoutes.GET("/invitations/rebates", requirePerm("distribution:settlement_view"), invitationHandler.AdminRebates) } diff --git a/frontend/src/features/admin/api/adminInvitations.ts b/frontend/src/features/admin/api/adminInvitations.ts index 72966e7..e97693d 100644 --- a/frontend/src/features/admin/api/adminInvitations.ts +++ b/frontend/src/features/admin/api/adminInvitations.ts @@ -56,6 +56,9 @@ export interface AdminInvitationQuery { invitee_phone?: string order_no?: string trade_role?: string + source?: string + start_date?: string + end_date?: string distribution_site_id?: number inviter_id?: number invitee_id?: number @@ -63,6 +66,43 @@ export interface AdminInvitationQuery { page_size?: number } +export interface AdminRebateDailyStat { + date: string + ledger_count: number + rebate_amount_cent: number + order_amount_cent: number + pickup_amount_cent: number +} + +export interface AdminRebateInviterStat { + inviter_id: number + inviter_phone: string + inviter_nickname: string + ledger_count: number + rebate_amount_cent: number +} + +export interface AdminRebateSummary { + total_rebate_cent: number + ledger_count: number + inviter_count: number + invitee_count: number + avg_rebate_cent: number + order_rebate_cent: number + order_count: number + pickup_rebate_cent: number + pickup_count: number + buyer_rebate_cent: number + buyer_count: number + seller_rebate_cent: number + seller_count: number + first_month_cent: number + second_month_cent: number + subsequent_cent: number + daily: AdminRebateDailyStat[] + top_inviters: AdminRebateInviterStat[] +} + export async function fetchAdminInvitations(query: AdminInvitationQuery = {}) { const { data } = await apiClient.get>>( '/admin/invitations', @@ -86,3 +126,31 @@ export async function fetchAdminRebates(query: AdminInvitationQuery = {}) { total: Number(result?.total ?? 0), } } + +export async function fetchAdminRebateSummary(query: AdminInvitationQuery = {}) { + const { data } = await apiClient.get>( + '/admin/invitations/rebates/summary', + { params: query } + ) + const result = data.data + return { + total_rebate_cent: Number(result?.total_rebate_cent ?? 0), + ledger_count: Number(result?.ledger_count ?? 0), + inviter_count: Number(result?.inviter_count ?? 0), + invitee_count: Number(result?.invitee_count ?? 0), + avg_rebate_cent: Number(result?.avg_rebate_cent ?? 0), + order_rebate_cent: Number(result?.order_rebate_cent ?? 0), + order_count: Number(result?.order_count ?? 0), + pickup_rebate_cent: Number(result?.pickup_rebate_cent ?? 0), + pickup_count: Number(result?.pickup_count ?? 0), + buyer_rebate_cent: Number(result?.buyer_rebate_cent ?? 0), + buyer_count: Number(result?.buyer_count ?? 0), + seller_rebate_cent: Number(result?.seller_rebate_cent ?? 0), + seller_count: Number(result?.seller_count ?? 0), + first_month_cent: Number(result?.first_month_cent ?? 0), + second_month_cent: Number(result?.second_month_cent ?? 0), + subsequent_cent: Number(result?.subsequent_cent ?? 0), + daily: Array.isArray(result?.daily) ? result.daily : [], + top_inviters: Array.isArray(result?.top_inviters) ? result.top_inviters : [], + } satisfies AdminRebateSummary +} diff --git a/frontend/src/features/admin/views/AdminInvitationsView.vue b/frontend/src/features/admin/views/AdminInvitationsView.vue index ee58ee8..d237fe6 100644 --- a/frontend/src/features/admin/views/AdminInvitationsView.vue +++ b/frontend/src/features/admin/views/AdminInvitationsView.vue @@ -1,12 +1,16 @@