Movement Analytics Backend
Adds movement analytics DTOs/service, registers the service, and adds repository queries for moved/pending carton counts.main
parent
b2b6d740e3
commit
ec86ae98e0
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
using AVSCartonShipmentVerifier.DTOs;
|
||||||
|
|
||||||
|
namespace AVSCartonShipmentVerifier.Services;
|
||||||
|
|
||||||
|
public interface IMovementAnalyticsService
|
||||||
|
{
|
||||||
|
Task<MovementAnalyticsDto> GetAnalyticsAsync(DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue