using System.Globalization; using ClosedXML.Excel; 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; private readonly IServiceRepository _serviceRepository; public StatsService( IDailyStatRepository dailyStatRepository, IPushSendLogRepository pushSendLogRepository, IDeviceRepository deviceRepository, IMessageRepository messageRepository, IServiceRepository serviceRepository) { _dailyStatRepository = dailyStatRepository; _pushSendLogRepository = pushSendLogRepository; _deviceRepository = deviceRepository; _messageRepository = messageRepository; _serviceRepository = serviceRepository; } 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 = serviceId.HasValue ? await _deviceRepository.CountAsync(d => d.ServiceId == serviceId.Value) : await _deviceRepository.CountAsync(d => true); var activeDevices = serviceId.HasValue ? await _deviceRepository.CountAsync(d => d.ServiceId == serviceId.Value && d.IsActive) : await _deviceRepository.CountAsync(d => d.IsActive); var totalMessages = serviceId.HasValue ? await _messageRepository.CountAsync(m => m.ServiceId == serviceId.Value && !m.IsDeleted) : await _messageRepository.CountAsync(m => !m.IsDeleted); var allStats = serviceId.HasValue ? await _dailyStatRepository.FindAsync(s => s.ServiceId == serviceId.Value) : await _dailyStatRepository.FindAsync(s => true); 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 = serviceId.HasValue ? await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId.Value) : await _messageRepository.GetByMessageCodeAsync(request.MessageCode); if (message == null) throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); DateTime? startDate = ParseOptionalDate(request.StartDate); DateTime? endDate = ParseOptionalDate(request.EndDate); var stats = await _pushSendLogRepository.GetMessageStatsAsync(message.ServiceId, message.Id, startDate, endDate); var dailyStats = await _pushSendLogRepository.GetMessageDailyStatsAsync(message.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 = serviceId.HasValue ? await _deviceRepository.FindAsync(d => d.ServiceId == serviceId.Value && d.IsActive) : await _deviceRepository.FindAsync(d => 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 = serviceId.HasValue ? await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId.Value) : await _messageRepository.GetByMessageCodeAsync(request.MessageCode); 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( message.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 } }; } public async Task ExportReportAsync(long? serviceId, StatsExportRequestDto request) { var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); using var workbook = new XLWorkbook(); // Sheet 1: 일별 통계 var dailyStats = await _dailyStatRepository.GetByDateRangeAsync(serviceId, startDate, endDate); var ws1 = workbook.Worksheets.Add("일별 통계"); ws1.Cell(1, 1).Value = "날짜"; ws1.Cell(1, 2).Value = "발송"; ws1.Cell(1, 3).Value = "성공"; ws1.Cell(1, 4).Value = "실패"; ws1.Cell(1, 5).Value = "열람"; ws1.Cell(1, 6).Value = "CTR(%)"; ws1.Row(1).Style.Font.Bold = true; var row = 2; foreach (var s in dailyStats.OrderBy(s => s.StatDate)) { ws1.Cell(row, 1).Value = s.StatDate.ToString("yyyy-MM-dd"); ws1.Cell(row, 2).Value = s.SentCnt; ws1.Cell(row, 3).Value = s.SuccessCnt; ws1.Cell(row, 4).Value = s.FailCnt; ws1.Cell(row, 5).Value = s.OpenCnt; ws1.Cell(row, 6).Value = CalcCtr(s.OpenCnt, s.SuccessCnt); row++; } ws1.Columns().AdjustToContents(); // Sheet 2: 시간대별 통계 var startDateTime = startDate.ToDateTime(TimeOnly.MinValue); var endDateTime = endDate.ToDateTime(TimeOnly.MinValue).AddDays(1); var hourlyStats = await _pushSendLogRepository.GetHourlyStatsAsync(serviceId, startDateTime, endDateTime); var ws2 = workbook.Worksheets.Add("시간대별 통계"); ws2.Cell(1, 1).Value = "시간"; ws2.Cell(1, 2).Value = "발송"; ws2.Cell(1, 3).Value = "성공"; ws2.Cell(1, 4).Value = "실패"; ws2.Row(1).Style.Font.Bold = true; for (var hour = 0; hour < 24; hour++) { var stat = hourlyStats.FirstOrDefault(h => h.Hour == hour); ws2.Cell(hour + 2, 1).Value = $"{hour:D2}:00"; ws2.Cell(hour + 2, 2).Value = stat?.SendCount ?? 0; ws2.Cell(hour + 2, 3).Value = stat?.SuccessCount ?? 0; ws2.Cell(hour + 2, 4).Value = stat?.FailCount ?? 0; } ws2.Columns().AdjustToContents(); // Sheet 3: 플랫폼별 통계 var devices = serviceId.HasValue ? await _deviceRepository.FindAsync(d => d.ServiceId == serviceId.Value && d.IsActive) : await _deviceRepository.FindAsync(d => d.IsActive); var total = devices.Count; var ws3 = workbook.Worksheets.Add("플랫폼별 통계"); ws3.Cell(1, 1).Value = "플랫폼"; ws3.Cell(1, 2).Value = "수량"; ws3.Cell(1, 3).Value = "비율(%)"; ws3.Row(1).Style.Font.Bold = true; var platformGroups = devices .GroupBy(d => d.Platform) .OrderByDescending(g => g.Count()) .ToList(); row = 2; foreach (var g in platformGroups) { ws3.Cell(row, 1).Value = g.Key.ToString(); ws3.Cell(row, 2).Value = g.Count(); ws3.Cell(row, 3).Value = total > 0 ? Math.Round((double)g.Count() / total * 100, 2) : 0; row++; } ws3.Columns().AdjustToContents(); using var stream = new MemoryStream(); workbook.SaveAs(stream); return stream.ToArray(); } public async Task GetFailureStatAsync(long? serviceId, FailureStatRequestDto request) { var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); var startDateTime = startDate.ToDateTime(TimeOnly.MinValue); var endDateTime = endDate.ToDateTime(TimeOnly.MinValue).AddDays(1); var failureStats = await _pushSendLogRepository.GetFailureStatsAsync(serviceId, startDateTime, endDateTime, request.Limit); var totalFail = failureStats.Sum(f => f.Count); var items = failureStats.Select(f => new FailureStatItemDto { FailReason = f.FailReason, Count = f.Count, Ratio = totalFail > 0 ? Math.Round((double)f.Count / totalFail * 100, 2) : 0, Description = GetFailReasonDescription(f.FailReason) }).ToList(); return new FailureStatResponseDto { TotalFail = totalFail, Period = new FailureStatPeriodDto { StartDate = request.StartDate, EndDate = request.EndDate }, Items = items }; } public async Task GetDashboardAsync(long? serviceId, DashboardRequestDto request) { // EF Core DbContext는 thread-safe하지 않으므로 순차 실행 var summary = await GetSummaryAsync(serviceId); var daily = await GetDailyAsync(serviceId, new DailyStatRequestDto { StartDate = request.StartDate, EndDate = request.EndDate }); var hourly = await GetHourlyAsync(serviceId, new HourlyStatRequestDto { StartDate = request.StartDate, EndDate = request.EndDate }); var device = await GetDeviceStatAsync(serviceId); var activeServiceCount = await _serviceRepository.CountAsync(s => s.Status == ServiceStatus.Active && !s.IsDeleted); var topMessages = await _messageRepository.GetTopBySendCountAsync(serviceId, 5); // KPI 변화량 계산 var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); // 1) success_rate_change: 직전 동일 기간 대비 성공률 변화 (pp) var duration = endDate.DayNumber - startDate.DayNumber + 1; var prevEnd = startDate.AddDays(-1); var prevStart = prevEnd.AddDays(-(duration - 1)); var prevStats = await _dailyStatRepository.GetByDateRangeAsync(serviceId, prevStart, prevEnd); var currentSend = daily.Summary.TotalSend; var currentSuccess = daily.Summary.TotalSuccess; var prevSend = prevStats.Sum(s => (long)s.SentCnt); var prevSuccess = prevStats.Sum(s => (long)s.SuccessCnt); var currentRate = currentSend > 0 ? (double)currentSuccess / currentSend * 100 : 0; var prevRate = prevSend > 0 ? (double)prevSuccess / prevSend * 100 : 0; var successRateChange = prevSend > 0 ? Math.Round(currentRate - prevRate, 1) : 0; // 2) device_count_change: 오늘 신규 등록 디바이스 수 var todayDateTime = DateTime.UtcNow.Date; var tomorrowDateTime = todayDateTime.AddDays(1); var deviceCountChange = serviceId.HasValue ? await _deviceRepository.CountAsync(d => d.ServiceId == serviceId.Value && d.CreatedAt >= todayDateTime && d.CreatedAt < tomorrowDateTime) : await _deviceRepository.CountAsync(d => d.CreatedAt >= todayDateTime && d.CreatedAt < tomorrowDateTime); // 3) today_sent_change_rate: 오늘 vs 어제 발송 수 변화율 (%) var today = DateOnly.FromDateTime(DateTime.UtcNow); var yesterday = today.AddDays(-1); var yesterdayStats = await _dailyStatRepository.GetByDateRangeAsync(serviceId, yesterday, yesterday); var yesterdaySent = yesterdayStats.Sum(s => s.SentCnt); var todaySent = summary.Today.SendCount; var todaySentChangeRate = yesterdaySent > 0 ? Math.Round((double)(todaySent - yesterdaySent) / yesterdaySent * 100, 1) : 0; return new DashboardResponseDto { Kpi = new DashboardKpiDto { TotalDevices = summary.TotalDevices, ActiveDevices = summary.ActiveDevices, TotalMessages = summary.TotalMessages, TotalSend = summary.TotalSend, TotalSuccess = summary.TotalSuccess, TotalOpen = summary.TotalOpen, AvgCtr = summary.AvgCtr, ActiveServiceCount = activeServiceCount, SuccessRateChange = successRateChange, DeviceCountChange = deviceCountChange, TodaySentChangeRate = todaySentChangeRate, Today = summary.Today, ThisMonth = summary.ThisMonth }, Daily = daily.Items, Hourly = hourly.Items, PlatformShare = device.ByPlatform, TopMessages = topMessages.Select(m => new TopMessageDto { MessageCode = m.MessageCode, Title = m.Title, ServiceName = m.ServiceName, TotalSendCount = m.TotalSendCount, SuccessCount = m.SuccessCount, Status = SendStatus.Determine(m.TotalSendCount, m.SuccessCount) }).ToList() }; } public async Task GetHistoryListAsync(long? serviceId, HistoryListRequestDto request) { DateTime? startDate = ParseOptionalDate(request.StartDate); DateTime? endDate = ParseOptionalDate(request.EndDate); var (items, totalCount) = await _pushSendLogRepository.GetMessageHistoryPagedAsync( serviceId, request.Page, request.Size, request.Keyword, request.Status, startDate, endDate); var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); return new HistoryListResponseDto { Items = items.Select(i => new HistoryListItemDto { MessageCode = i.MessageCode, Title = i.Title, ServiceName = i.ServiceName, SentAt = i.FirstSentAt, TargetCount = i.TotalSendCount, SuccessCount = i.SuccessCount, FailCount = i.FailCount, OpenRate = 0, Status = SendStatus.Determine(i.TotalSendCount, i.SuccessCount) }).ToList(), Pagination = new DTOs.Notice.PaginationDto { Page = request.Page, Size = request.Size, TotalCount = totalCount, TotalPages = totalPages } }; } public async Task GetHistoryDetailAsync(long? serviceId, HistoryDetailRequestDto request) { if (string.IsNullOrWhiteSpace(request.MessageCode)) throw new SpmsException(ErrorCodes.BadRequest, "message_code는 필수입니다.", 400); var message = serviceId.HasValue ? await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId.Value) : await _messageRepository.GetByMessageCodeAsync(request.MessageCode); if (message == null) throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); // 집계 var stats = await _pushSendLogRepository.GetMessageStatsAsync(message.ServiceId, message.Id, null, null); var totalSend = stats?.TotalSend ?? 0; var totalSuccess = stats?.TotalSuccess ?? 0; var totalFail = stats?.TotalFail ?? 0; var successRate = totalSend > 0 ? Math.Round((double)totalSuccess / totalSend * 100, 2) : 0; // 실패사유 Top 5 var failureStats = await _pushSendLogRepository.GetFailureStatsByMessageAsync(message.ServiceId, message.Id, 5); // 서비스명 var service = await _serviceRepository.GetByIdAsync(message.ServiceId); return new HistoryDetailResponseDto { MessageCode = message.MessageCode, Title = message.Title, Body = message.Body, ServiceName = service?.ServiceName ?? string.Empty, FirstSentAt = stats?.FirstSentAt, LastSentAt = stats?.LastSentAt, Status = SendStatus.Determine(totalSend, totalSuccess), TargetCount = totalSend, SuccessCount = totalSuccess, FailCount = totalFail, SuccessRate = successRate, OpenCount = 0, OpenRate = 0, FailReasons = failureStats.Select(f => new HistoryFailReasonDto { Reason = f.FailReason, Count = f.Count, Description = GetFailReasonDescription(f.FailReason) }).ToList() }; } public async Task ExportHistoryAsync(long? serviceId, HistoryExportRequestDto request) { DateTime? startDate = ParseOptionalDate(request.StartDate); DateTime? endDate = ParseOptionalDate(request.EndDate); var items = await _pushSendLogRepository.GetMessageHistoryAllAsync( serviceId, request.Keyword, request.Status, startDate, endDate); using var workbook = new XLWorkbook(); var ws = workbook.Worksheets.Add("발송 이력"); // 헤더 ws.Cell(1, 1).Value = "메시지코드"; ws.Cell(1, 2).Value = "제목"; ws.Cell(1, 3).Value = "서비스명"; ws.Cell(1, 4).Value = "발송일시"; ws.Cell(1, 5).Value = "대상수"; ws.Cell(1, 6).Value = "성공"; ws.Cell(1, 7).Value = "실패"; ws.Cell(1, 8).Value = "오픈율(%)"; ws.Cell(1, 9).Value = "상태"; ws.Row(1).Style.Font.Bold = true; var row = 2; foreach (var item in items) { ws.Cell(row, 1).Value = item.MessageCode; ws.Cell(row, 2).Value = item.Title; ws.Cell(row, 3).Value = item.ServiceName; ws.Cell(row, 4).Value = item.FirstSentAt.ToString("yyyy-MM-dd HH:mm:ss"); ws.Cell(row, 5).Value = item.TotalSendCount; ws.Cell(row, 6).Value = item.SuccessCount; ws.Cell(row, 7).Value = item.FailCount; ws.Cell(row, 8).Value = 0; ws.Cell(row, 9).Value = SendStatus.Determine(item.TotalSendCount, item.SuccessCount); row++; } ws.Columns().AdjustToContents(); using var stream = new MemoryStream(); workbook.SaveAs(stream); return stream.ToArray(); } private static string GetFailReasonDescription(string failReason) { return failReason switch { "InvalidToken" => "만료/잘못된 디바이스 토큰", "NotRegistered" => "FCM/APNs에 미등록", "MessageTooBig" => "페이로드 크기 초과 (4KB)", "RateLimit" => "FCM/APNs 발송 제한 초과", "ServerError" => "FCM/APNs 서버 오류", "NetworkError" => "네트워크 연결 실패", "Unknown" => "분류 불가", _ => failReason }; } 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.StatsDateRangeInvalid, "start_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400); if (!DateOnly.TryParseExact(endStr, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end)) throw new SpmsException(ErrorCodes.StatsDateRangeInvalid, "end_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400); if (start > end) throw new SpmsException(ErrorCodes.StatsDateRangeInvalid, "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; } }