Movement Analytics Backend

Adds movement analytics DTOs/service, registers the service, and adds repository queries for moved/pending carton counts.
main
SYED MUSTUFA AHMED NAQVI 2026-05-15 18:12:52 +05:00
parent b2b6d740e3
commit ec86ae98e0
6 changed files with 276 additions and 0 deletions

View File

@ -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<MovementChartDayDto> ChartByDay { get; init; } = [];
public IReadOnlyList<MovementDayDetailDto> 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; }
}

View File

@ -42,6 +42,7 @@ builder.Services.AddScoped<IScanHistoryStore, SqliteScanHistoryStore>();
builder.Services.AddScoped<IVerificationDbConnectionFactory, VerificationDbConnectionFactory>(); builder.Services.AddScoped<IVerificationDbConnectionFactory, VerificationDbConnectionFactory>();
builder.Services.AddScoped<ICartonVerificationRepository, CartonVerificationRepository>(); builder.Services.AddScoped<ICartonVerificationRepository, CartonVerificationRepository>();
builder.Services.AddScoped<IShipmentVerificationService, ShipmentVerificationService>(); builder.Services.AddScoped<IShipmentVerificationService, ShipmentVerificationService>();
builder.Services.AddScoped<IMovementAnalyticsService, MovementAnalyticsService>();
var app = builder.Build(); var app = builder.Build();

View File

@ -57,6 +57,38 @@ public sealed class CartonVerificationRepository(
return affectedRows > 0; return affectedRows > 0;
} }
public async Task<int> 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<int> 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) private static string QuoteIdentifier(string identifier)
{ {
if (!SqlIdentifierRegex.IsMatch(identifier)) if (!SqlIdentifierRegex.IsMatch(identifier))

View File

@ -5,5 +5,9 @@ public interface ICartonVerificationRepository
Task<CartonVerificationStatus> GetCartonStatusByUniqueNumberAsync(string uniqueNumber, CancellationToken cancellationToken = default); Task<CartonVerificationStatus> GetCartonStatusByUniqueNumberAsync(string uniqueNumber, CancellationToken cancellationToken = default);
Task<bool> MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default); Task<bool> MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default);
Task<int> GetAvailableCartonCountAsync(CancellationToken cancellationToken = default);
Task<int> GetTotalMovedCartonCountAsync(CancellationToken cancellationToken = default);
} }

View File

@ -0,0 +1,8 @@
using AVSCartonShipmentVerifier.DTOs;
namespace AVSCartonShipmentVerifier.Services;
public interface IMovementAnalyticsService
{
Task<MovementAnalyticsDto> GetAnalyticsAsync(DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default);
}

View File

@ -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<MovementAnalyticsService> logger) : IMovementAnalyticsService
{
private readonly ScanHistoryDbContext _dbContext = dbContext;
private readonly ICartonVerificationRepository _cartonVerificationRepository = cartonVerificationRepository;
private readonly ILogger<MovementAnalyticsService> _logger = logger;
public async Task<MovementAnalyticsDto> 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<ScanHistoryEntry> successfulInRange;
List<RejectedScanEntry> 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<MovementChartDayDto>();
var dayDetails = new List<MovementDayDetailDto>();
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<int> 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);
}
}