using AVSCartonShipmentVerifier.DTOs; using AVSCartonShipmentVerifier.Repositories; using AVSCartonShipmentVerifier.ScanHistory; using Microsoft.EntityFrameworkCore; namespace AVSCartonShipmentVerifier.Services; public sealed class MovementAnalyticsService( ScanHistoryDbContext dbContext, ICartonVerificationRepository cartonVerificationRepository, ILogger logger) : IMovementAnalyticsService { private readonly ScanHistoryDbContext _dbContext = dbContext; private readonly ICartonVerificationRepository _cartonVerificationRepository = cartonVerificationRepository; private readonly ILogger _logger = logger; public async Task GetAnalyticsAsync(DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default) { if (toDate < fromDate) { (fromDate, toDate) = (toDate, fromDate); } var today = DateOnly.FromDateTime(DateTime.Now); var (todayStartUtc, todayEndUtc) = GetUtcDayRange(today); var (weekStartUtc, weekEndUtc) = GetUtcWeekRange(today); var (monthStartUtc, monthEndUtc) = GetUtcMonthRange(today); var (rangeStartUtc, rangeEndUtc) = GetUtcDayRange(fromDate, toDate); int movedToday; int movedThisWeek; int movedThisMonth; int totalMoved; int pending; List successfulInRange; List rejectedInRange; try { movedToday = await CountSuccessfulScansAsync(todayStartUtc, todayEndUtc, cancellationToken); movedThisWeek = await CountSuccessfulScansAsync(weekStartUtc, weekEndUtc, cancellationToken); movedThisMonth = await CountSuccessfulScansAsync(monthStartUtc, monthEndUtc, cancellationToken); totalMoved = await _cartonVerificationRepository.GetTotalMovedCartonCountAsync(cancellationToken); pending = await _cartonVerificationRepository.GetAvailableCartonCountAsync(cancellationToken); successfulInRange = await _dbContext.ScanHistoryEntries .AsNoTracking() .Where(x => x.ShipmentMarked && x.ProcessedAtUtc >= rangeStartUtc && x.ProcessedAtUtc <= rangeEndUtc) .ToListAsync(cancellationToken); rejectedInRange = await _dbContext.RejectedScanEntries .AsNoTracking() .Where(x => x.ProcessedAtUtc >= rangeStartUtc && x.ProcessedAtUtc <= rangeEndUtc) .ToListAsync(cancellationToken); } catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested) { _logger.LogDebug(exception, "Movement analytics read was cancelled."); throw; } catch (Exception exception) { _logger.LogError(exception, "Failed to load movement analytics."); throw; } var successfulByLocalDate = successfulInRange .GroupBy(x => ToLocalDateOnly(x.ProcessedAtUtc)) .ToDictionary(g => g.Key, g => g.ToList()); var rejectedByLocalDate = rejectedInRange .GroupBy(x => ToLocalDateOnly(x.ProcessedAtUtc)) .ToDictionary(g => g.Key, g => g.ToList()); var chartByDay = new List(); var dayDetails = new List(); for (var date = fromDate; date <= toDate; date = date.AddDays(1)) { successfulByLocalDate.TryGetValue(date, out var daySuccessful); daySuccessful ??= []; rejectedByLocalDate.TryGetValue(date, out var dayRejected); dayRejected ??= []; var movedCount = daySuccessful.Count; var duplicateCount = dayRejected.Count(r => string.Equals(r.RejectionReason, VerificationRejectionReasons.AlreadyTransferredDuplicateScan, StringComparison.Ordinal)); var manualCount = dayRejected.Count(r => string.Equals(r.RejectionReason, VerificationRejectionReasons.ManualEntryNotAllowed, StringComparison.Ordinal)); DateTime? lastMovedUtc = daySuccessful.Count > 0 ? daySuccessful.Max(x => x.ProcessedAtUtc) : null; var daySpan = toDate.DayNumber - fromDate.DayNumber + 1; var dayLabel = daySpan <= 7 ? date.ToString("ddd", System.Globalization.CultureInfo.InvariantCulture) : date.ToString("MMM d", System.Globalization.CultureInfo.InvariantCulture); chartByDay.Add(new MovementChartDayDto { DayLabel = dayLabel, MovedCartons = movedCount }); dayDetails.Add(new MovementDayDetailDto { Date = date, DateDisplay = $"{date.Day} {date:MMM yyyy} ({date:ddd})", MovedCartons = movedCount, SuccessfulScans = movedCount, DuplicateRejections = duplicateCount, ManualEntryRejections = manualCount, LastMovedAtUtc = lastMovedUtc }); } dayDetails.Reverse(); return new MovementAnalyticsDto { Summary = new MovementSummaryDto { MovedToday = movedToday, MovedThisWeek = movedThisWeek, MovedThisMonth = movedThisMonth, TotalMoved = totalMoved }, StatusShare = new MovementStatusShareDto { Moved = totalMoved, Pending = pending }, ChartByDay = chartByDay, DayDetails = dayDetails, DateRangeLabel = $"{fromDate:MMM d, yyyy} - {toDate:MMM d, yyyy}" }; } private async Task CountSuccessfulScansAsync(DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken) => await _dbContext.ScanHistoryEntries .AsNoTracking() .CountAsync(x => x.ShipmentMarked && x.ProcessedAtUtc >= startUtc && x.ProcessedAtUtc <= endUtc, cancellationToken); private static DateOnly ToLocalDateOnly(DateTime utc) => DateOnly.FromDateTime(DateTime.SpecifyKind(utc, DateTimeKind.Utc).ToLocalTime()); private static (DateTime StartUtc, DateTime EndUtc) GetUtcDayRange(DateOnly date) => GetUtcDayRange(date, date); private static (DateTime StartUtc, DateTime EndUtc) GetUtcDayRange(DateOnly from, DateOnly to) { var startLocal = from.ToDateTime(TimeOnly.MinValue); var endLocal = to.ToDateTime(new TimeOnly(23, 59, 59, 999)); return (TimeZoneInfo.ConvertTimeToUtc(startLocal), TimeZoneInfo.ConvertTimeToUtc(endLocal)); } private static (DateTime StartUtc, DateTime EndUtc) GetUtcWeekRange(DateOnly anchor) { var local = anchor.ToDateTime(TimeOnly.MinValue); var diff = (7 + (local.DayOfWeek - DayOfWeek.Monday)) % 7; var weekStart = local.AddDays(-diff); var weekEnd = weekStart.AddDays(6); return GetUtcDayRange(DateOnly.FromDateTime(weekStart), DateOnly.FromDateTime(weekEnd)); } private static (DateTime StartUtc, DateTime EndUtc) GetUtcMonthRange(DateOnly anchor) { var monthStart = new DateOnly(anchor.Year, anchor.Month, 1); var monthEnd = monthStart.AddMonths(1).AddDays(-1); return GetUtcDayRange(monthStart, monthEnd); } }