From ec86ae98e0eb00f31ca84b12df7b7fd371043bf4 Mon Sep 17 00:00:00 2001 From: Syed Mustafa Ahmed Naqvi Date: Fri, 15 May 2026 18:12:52 +0500 Subject: [PATCH] Movement Analytics Backend Adds movement analytics DTOs/service, registers the service, and adds repository queries for moved/pending carton counts. --- DTOs/MovementAnalyticsDto.cs | 62 +++++++ Program.cs | 1 + Repositories/CartonVerificationRepository.cs | 32 ++++ Repositories/ICartonVerificationRepository.cs | 4 + Services/IMovementAnalyticsService.cs | 8 + Services/MovementAnalyticsService.cs | 169 ++++++++++++++++++ 6 files changed, 276 insertions(+) create mode 100644 DTOs/MovementAnalyticsDto.cs create mode 100644 Services/IMovementAnalyticsService.cs create mode 100644 Services/MovementAnalyticsService.cs diff --git a/DTOs/MovementAnalyticsDto.cs b/DTOs/MovementAnalyticsDto.cs new file mode 100644 index 0000000..8538b64 --- /dev/null +++ b/DTOs/MovementAnalyticsDto.cs @@ -0,0 +1,62 @@ +namespace AVSCartonShipmentVerifier.DTOs; + +public sealed class MovementAnalyticsDto +{ + public MovementSummaryDto Summary { get; init; } = new(); + + public MovementStatusShareDto StatusShare { get; init; } = new(); + + public IReadOnlyList ChartByDay { get; init; } = []; + + public IReadOnlyList DayDetails { get; init; } = []; + + public string DateRangeLabel { get; init; } = string.Empty; +} + +public sealed class MovementSummaryDto +{ + public int MovedToday { get; init; } + + public int MovedThisWeek { get; init; } + + public int MovedThisMonth { get; init; } + + public int TotalMoved { get; init; } +} + +public sealed class MovementStatusShareDto +{ + public int Moved { get; init; } + + public int Pending { get; init; } + + public int Total => Moved + Pending; + + public double MovedPercent => Total > 0 ? Math.Round(Moved * 100.0 / Total, 1) : 0; + + public double PendingPercent => Total > 0 ? Math.Round(Pending * 100.0 / Total, 1) : 0; +} + +public sealed class MovementChartDayDto +{ + public string DayLabel { get; init; } = string.Empty; + + public int MovedCartons { get; init; } +} + +public sealed class MovementDayDetailDto +{ + public DateOnly Date { get; init; } + + public string DateDisplay { get; init; } = string.Empty; + + public int MovedCartons { get; init; } + + public int SuccessfulScans { get; init; } + + public int DuplicateRejections { get; init; } + + public int ManualEntryRejections { get; init; } + + public DateTime? LastMovedAtUtc { get; init; } +} diff --git a/Program.cs b/Program.cs index 9ccea32..6fefd55 100644 --- a/Program.cs +++ b/Program.cs @@ -42,6 +42,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/Repositories/CartonVerificationRepository.cs b/Repositories/CartonVerificationRepository.cs index 2d07d48..c815ece 100644 --- a/Repositories/CartonVerificationRepository.cs +++ b/Repositories/CartonVerificationRepository.cs @@ -57,6 +57,38 @@ public sealed class CartonVerificationRepository( return affectedRows > 0; } + public async Task GetAvailableCartonCountAsync(CancellationToken cancellationToken = default) + { + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = $""" + SELECT COUNT(*) + FROM {QuoteIdentifier(_options.TableName)} + WHERE {QuoteIdentifier(_options.MovedToWarehouseColumnName)} = 0; + """; + + var result = await command.ExecuteScalarAsync(cancellationToken); + return Convert.ToInt32(result); + } + + public async Task GetTotalMovedCartonCountAsync(CancellationToken cancellationToken = default) + { + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = $""" + SELECT COUNT(*) + FROM {QuoteIdentifier(_options.TableName)} + WHERE {QuoteIdentifier(_options.MovedToWarehouseColumnName)} = 1; + """; + + var result = await command.ExecuteScalarAsync(cancellationToken); + return Convert.ToInt32(result); + } + private static string QuoteIdentifier(string identifier) { if (!SqlIdentifierRegex.IsMatch(identifier)) diff --git a/Repositories/ICartonVerificationRepository.cs b/Repositories/ICartonVerificationRepository.cs index 8c7c99a..afb0f9a 100644 --- a/Repositories/ICartonVerificationRepository.cs +++ b/Repositories/ICartonVerificationRepository.cs @@ -5,5 +5,9 @@ public interface ICartonVerificationRepository Task GetCartonStatusByUniqueNumberAsync(string uniqueNumber, CancellationToken cancellationToken = default); Task MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default); + + Task GetAvailableCartonCountAsync(CancellationToken cancellationToken = default); + + Task GetTotalMovedCartonCountAsync(CancellationToken cancellationToken = default); } diff --git a/Services/IMovementAnalyticsService.cs b/Services/IMovementAnalyticsService.cs new file mode 100644 index 0000000..b1ad198 --- /dev/null +++ b/Services/IMovementAnalyticsService.cs @@ -0,0 +1,8 @@ +using AVSCartonShipmentVerifier.DTOs; + +namespace AVSCartonShipmentVerifier.Services; + +public interface IMovementAnalyticsService +{ + Task GetAnalyticsAsync(DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default); +} diff --git a/Services/MovementAnalyticsService.cs b/Services/MovementAnalyticsService.cs new file mode 100644 index 0000000..9eb0799 --- /dev/null +++ b/Services/MovementAnalyticsService.cs @@ -0,0 +1,169 @@ +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 (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); + } +}