using Microsoft.EntityFrameworkCore; using Tiku.Application.Growth; using Tiku.Domain.Commerce; namespace Tiku.Infrastructure.Growth; public sealed partial class ReferralService { public async Task GetStatsAsync( ReferralAdminActor actor, ReferralStatsQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var referrerUserId = query.ReferrerUserId ?? actor.UserId; await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); return await BuildStatsAsync(actor.TenantId, referrerUserId, cancellationToken); } public async Task> GetSalesStatsAsync( ReferralAdminActor actor, ReferralStatsQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var limit = Math.Clamp(query.Limit ?? 100, 1, 500); var referrerIdsQuery = dbContext.ReferralLeads .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId != null); if (query.ReferrerUserId.HasValue) referrerIdsQuery = referrerIdsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); var referrerIds = await referrerIdsQuery .Select(item => item.ReferrerUserId!.Value) .Distinct() .Take(limit) .ToArrayAsync(cancellationToken); var stats = new List(referrerIds.Length); foreach (var referrerId in referrerIds) stats.Add(await BuildStatsAsync(actor.TenantId, referrerId, cancellationToken)); return new ReferralList( stats .OrderByDescending(item => item.PaidAmountCents) .ThenByDescending(item => item.LeadCount) .Take(limit) .ToArray()); } public async Task GetConversionReportAsync( ReferralAdminActor actor, ReferralConversionQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var today = DateOnly.FromDateTime(DateTime.UtcNow); var endDate = query.EndDate ?? today; var startDate = query.StartDate ?? endDate.AddDays(-Math.Clamp(query.Days ?? 30, 1, 365) + 1); if (endDate < startDate) throw new ReferralException("Referral date range was invalid.", "invalid_date_range"); var start = startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); var endExclusive = endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); var leadsQuery = dbContext.ReferralLeads .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.BoundAt >= start && item.BoundAt < endExclusive); if (query.ReferrerUserId.HasValue) leadsQuery = leadsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value); var leads = await leadsQuery .OrderByDescending(item => item.BoundAt) .ToArrayAsync(cancellationToken); var paidStudentIds = await dbContext.Orders.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Status == OrderStatus.Paid && item.UserId != null && item.PaidAt != null && item.PaidAt >= start && item.PaidAt < endExclusive) .GroupBy(item => item.UserId!.Value) .Select(group => new { UserId = group.Key, AmountCents = group.Sum(item => item.AmountCents) }) .ToDictionaryAsync(item => item.UserId, item => item.AmountCents, cancellationToken); var paidLeadCount = leads.Count(item => paidStudentIds.ContainsKey(item.StudentUserId)); var paidAmountCents = leads.Sum(item => paidStudentIds.GetValueOrDefault(item.StudentUserId)); var top = await GetSalesStatsAsync( actor, new ReferralStatsQuery(query.ReferrerUserId, query.Limit ?? 20), cancellationToken); return new ReferralConversionReport( startDate, endDate, leads.Length, paidLeadCount, paidAmountCents, leads.Length == 0 ? 0m : Math.Round(paidLeadCount * 100m / leads.Length, 2), top.Items, leads .Where(item => !paidStudentIds.ContainsKey(item.StudentUserId)) .Take(Math.Clamp(query.Limit ?? 20, 1, 100)) .Select(item => ToLeadItem(item, false)) .ToArray()); } public async Task> GetClientsAsync( ReferralAdminActor actor, ReferralStatsQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var referrerUserId = query.ReferrerUserId ?? actor.UserId; await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken); var leads = await dbContext.ReferralLeads .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId == referrerUserId) .OrderByDescending(item => item.BoundAt) .Take(Math.Clamp(query.Limit ?? 100, 1, 500)) .ToArrayAsync(cancellationToken); return new ReferralList(leads.Select(item => ToLeadItem(item, false)).ToArray()); } }