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<ICartonVerificationRepository, CartonVerificationRepository>();
|
||||
builder.Services.AddScoped<IShipmentVerificationService, ShipmentVerificationService>();
|
||||
builder.Services.AddScoped<IMovementAnalyticsService, MovementAnalyticsService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,38 @@ public sealed class CartonVerificationRepository(
|
|||
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)
|
||||
{
|
||||
if (!SqlIdentifierRegex.IsMatch(identifier))
|
||||
|
|
|
|||
|
|
@ -5,5 +5,9 @@ public interface ICartonVerificationRepository
|
|||
Task<CartonVerificationStatus> GetCartonStatusByUniqueNumberAsync(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