using System.Globalization; using SPMS.Application.DTOs.Stats; using SPMS.Application.Interfaces; using SPMS.Domain.Common; using SPMS.Domain.Entities; using SPMS.Domain.Enums; using SPMS.Domain.Exceptions; using SPMS.Domain.Interfaces; namespace SPMS.Application.Services; public class StatsService : IStatsService { private readonly IDailyStatRepository _dailyStatRepository; private readonly IPushSendLogRepository _pushSendLogRepository; private readonly IDeviceRepository _deviceRepository; private readonly IMessageRepository _messageRepository; public StatsService( IDailyStatRepository dailyStatRepository, IPushSendLogRepository pushSendLogRepository, IDeviceRepository deviceRepository, IMessageRepository messageRepository) { _dailyStatRepository = dailyStatRepository; _pushSendLogRepository = pushSendLogRepository; _deviceRepository = deviceRepository; _messageRepository = messageRepository; } public async Task GetDailyAsync(long serviceId, DailyStatRequestDto request) { var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); var stats = await _dailyStatRepository.GetByDateRangeAsync(serviceId, startDate, endDate); var items = stats.Select(s => new DailyStatItemDto { StatDate = s.StatDate.ToString("yyyy-MM-dd"), SendCount = s.SentCnt, SuccessCount = s.SuccessCnt, FailCount = s.FailCnt, OpenCount = s.OpenCnt, Ctr = CalcCtr(s.OpenCnt, s.SuccessCnt) }).ToList(); var totalSend = stats.Sum(s => s.SentCnt); var totalSuccess = stats.Sum(s => s.SuccessCnt); var totalFail = stats.Sum(s => s.FailCnt); var totalOpen = stats.Sum(s => s.OpenCnt); return new DailyStatResponseDto { Items = items, Summary = new DailyStatSummaryDto { TotalSend = totalSend, TotalSuccess = totalSuccess, TotalFail = totalFail, TotalOpen = totalOpen, AvgCtr = CalcCtr(totalOpen, totalSuccess) } }; } public async Task GetSummaryAsync(long serviceId) { var totalDevices = await _deviceRepository.CountAsync(d => d.ServiceId == serviceId); var activeDevices = await _deviceRepository.CountAsync(d => d.ServiceId == serviceId && d.IsActive); var totalMessages = await _messageRepository.CountAsync(m => m.ServiceId == serviceId && !m.IsDeleted); var allStats = await _dailyStatRepository.FindAsync(s => s.ServiceId == serviceId); var totalSend = allStats.Sum(s => (long)s.SentCnt); var totalSuccess = allStats.Sum(s => (long)s.SuccessCnt); var totalOpen = allStats.Sum(s => (long)s.OpenCnt); var today = DateOnly.FromDateTime(DateTime.UtcNow); var todayStat = await _dailyStatRepository.GetByDateAsync(serviceId, today); var monthStart = new DateOnly(today.Year, today.Month, 1); var monthStats = await _dailyStatRepository.GetByDateRangeAsync(serviceId, monthStart, today); return new SummaryStatResponseDto { TotalDevices = totalDevices, ActiveDevices = activeDevices, TotalMessages = totalMessages, TotalSend = totalSend, TotalSuccess = totalSuccess, TotalOpen = totalOpen, AvgCtr = totalSuccess > 0 ? Math.Round((double)totalOpen / totalSuccess * 100, 2) : 0, Today = new PeriodStatDto { SendCount = todayStat?.SentCnt ?? 0, SuccessCount = todayStat?.SuccessCnt ?? 0, OpenCount = todayStat?.OpenCnt ?? 0, Ctr = CalcCtr(todayStat?.OpenCnt ?? 0, todayStat?.SuccessCnt ?? 0) }, ThisMonth = new PeriodStatDto { SendCount = monthStats.Sum(s => s.SentCnt), SuccessCount = monthStats.Sum(s => s.SuccessCnt), OpenCount = monthStats.Sum(s => s.OpenCnt), Ctr = CalcCtr(monthStats.Sum(s => s.OpenCnt), monthStats.Sum(s => s.SuccessCnt)) } }; } public async Task GetMessageStatAsync(long serviceId, MessageStatRequestDto request) { if (string.IsNullOrWhiteSpace(request.MessageCode)) throw new SpmsException(ErrorCodes.BadRequest, "message_code는 필수입니다.", 400); var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); if (message == null) throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); DateTime? startDate = ParseOptionalDate(request.StartDate); DateTime? endDate = ParseOptionalDate(request.EndDate); var stats = await _pushSendLogRepository.GetMessageStatsAsync(serviceId, message.Id, startDate, endDate); var dailyStats = await _pushSendLogRepository.GetMessageDailyStatsAsync(serviceId, message.Id, startDate, endDate); return new MessageStatResponseDto { MessageCode = message.MessageCode, Title = message.Title, TotalSend = stats?.TotalSend ?? 0, TotalSuccess = stats?.TotalSuccess ?? 0, TotalFail = stats?.TotalFail ?? 0, TotalOpen = 0, Ctr = 0, FirstSentAt = stats?.FirstSentAt, LastSentAt = stats?.LastSentAt, Daily = dailyStats.Select(d => new MessageDailyItemDto { StatDate = d.StatDate.ToString("yyyy-MM-dd"), SendCount = d.SendCount, OpenCount = 0, Ctr = 0 }).ToList() }; } public async Task GetHourlyAsync(long serviceId, HourlyStatRequestDto request) { var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); var startDateTime = startDate.ToDateTime(TimeOnly.MinValue); var endDateTime = endDate.ToDateTime(TimeOnly.MinValue).AddDays(1); var hourlyStats = await _pushSendLogRepository.GetHourlyStatsAsync(serviceId, startDateTime, endDateTime); var items = Enumerable.Range(0, 24).Select(hour => { var stat = hourlyStats.FirstOrDefault(h => h.Hour == hour); return new HourlyStatItemDto { Hour = hour, SendCount = stat?.SendCount ?? 0, OpenCount = 0, Ctr = 0 }; }).ToList(); var bestHours = items .Where(i => i.SendCount > 0) .OrderByDescending(i => i.SendCount) .Take(3) .Select(i => i.Hour) .ToList(); return new HourlyStatResponseDto { Items = items, BestHours = bestHours }; } public async Task GetDeviceStatAsync(long serviceId) { var devices = await _deviceRepository.FindAsync(d => d.ServiceId == serviceId && d.IsActive); var total = devices.Count; var byPlatform = devices .GroupBy(d => d.Platform) .Select(g => new PlatformStatDto { Platform = g.Key.ToString().ToLowerInvariant(), Count = g.Count(), Ratio = total > 0 ? Math.Round((double)g.Count() / total * 100, 2) : 0 }) .OrderByDescending(p => p.Count) .ToList(); var byPushAgreed = devices .GroupBy(d => d.PushAgreed) .Select(g => new PushAgreedStatDto { Agreed = g.Key, Count = g.Count(), Ratio = total > 0 ? Math.Round((double)g.Count() / total * 100, 2) : 0 }) .OrderByDescending(p => p.Count) .ToList(); var tagCounts = new Dictionary(); foreach (var device in devices) { if (string.IsNullOrWhiteSpace(device.Tags)) continue; var tags = device.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); foreach (var tag in tags) { tagCounts.TryGetValue(tag, out var count); tagCounts[tag] = count + 1; } } var byTag = tagCounts .OrderByDescending(kv => kv.Value) .Select((kv, index) => new TagStatDto { TagIndex = index, TagName = kv.Key, Count = kv.Value }) .ToList(); return new DeviceStatResponseDto { Total = total, ByPlatform = byPlatform, ByPushAgreed = byPushAgreed, ByTag = byTag }; } public async Task GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request) { var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); if (message == null) throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); PushResult? status = null; if (!string.IsNullOrWhiteSpace(request.Status)) { status = request.Status.ToLowerInvariant() switch { "success" => PushResult.Success, "failed" => PushResult.Failed, _ => null }; } Platform? platform = null; if (!string.IsNullOrWhiteSpace(request.Platform)) { platform = request.Platform.ToLowerInvariant() switch { "ios" => Domain.Enums.Platform.iOS, "android" => Domain.Enums.Platform.Android, "web" => Domain.Enums.Platform.Web, _ => null }; } var (items, totalCount) = await _pushSendLogRepository.GetDetailLogPagedAsync( serviceId, message.Id, request.Page, request.Size, status, platform); var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); return new SendLogDetailResponseDto { Items = items.Select(l => new SendLogDetailItemDto { DeviceId = l.DeviceId, DeviceToken = l.Device?.DeviceToken ?? string.Empty, Platform = l.Device?.Platform.ToString().ToLowerInvariant() ?? string.Empty, Status = l.Status.ToString().ToLowerInvariant(), FailReason = l.FailReason, SentAt = l.SentAt }).ToList(), Pagination = new DTOs.Notice.PaginationDto { Page = request.Page, Size = request.Size, TotalCount = totalCount, TotalPages = totalPages } }; } private static double CalcCtr(int openCount, int successCount) { return successCount > 0 ? Math.Round((double)openCount / successCount * 100, 2) : 0; } private static (DateOnly Start, DateOnly End) ParseDateRange(string startStr, string endStr) { if (!DateOnly.TryParseExact(startStr, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start)) throw new SpmsException(ErrorCodes.BadRequest, "start_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400); if (!DateOnly.TryParseExact(endStr, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end)) throw new SpmsException(ErrorCodes.BadRequest, "end_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400); if (start > end) throw new SpmsException(ErrorCodes.BadRequest, "start_date가 end_date보다 클 수 없습니다.", 400); return (start, end); } private static DateTime? ParseOptionalDate(string? dateStr) { if (string.IsNullOrWhiteSpace(dateStr)) return null; return DateTime.TryParseExact(dateStr, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null; } }