Track successful and rejected scan history
Extends scan history storage to include rejected scans, updates SQLite/in-memory stores, and exposes rejected history to the dashboard model.main
parent
b26af6ef4b
commit
1ffe45c76b
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace AVSCartonShipmentVerifier.ScanHistory;
|
||||||
|
|
||||||
|
public sealed class RejectedScanEntry
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string RawInputValue { get; set; } = string.Empty;
|
||||||
|
public string ModelNumber { get; set; } = string.Empty;
|
||||||
|
public string UniqueNumber { get; set; } = string.Empty;
|
||||||
|
public string RejectionReason { get; set; } = string.Empty;
|
||||||
|
public DateTime ProcessedAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ public sealed class ScanHistoryDbContext(DbContextOptions<ScanHistoryDbContext>
|
||||||
{
|
{
|
||||||
public DbSet<ScanHistoryEntry> ScanHistoryEntries => Set<ScanHistoryEntry>();
|
public DbSet<ScanHistoryEntry> ScanHistoryEntries => Set<ScanHistoryEntry>();
|
||||||
|
|
||||||
|
public DbSet<RejectedScanEntry> RejectedScanEntries => Set<RejectedScanEntry>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<ScanHistoryEntry>(entity =>
|
modelBuilder.Entity<ScanHistoryEntry>(entity =>
|
||||||
|
|
@ -17,5 +19,17 @@ public sealed class ScanHistoryDbContext(DbContextOptions<ScanHistoryDbContext>
|
||||||
entity.Property(x => x.ProcessedAtUtc);
|
entity.Property(x => x.ProcessedAtUtc);
|
||||||
entity.HasIndex(x => x.ProcessedAtUtc);
|
entity.HasIndex(x => x.ProcessedAtUtc);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RejectedScanEntry>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("rejected_scan_entries");
|
||||||
|
entity.HasKey(x => x.Id);
|
||||||
|
entity.Property(x => x.RawInputValue).HasMaxLength(512);
|
||||||
|
entity.Property(x => x.ModelNumber).HasMaxLength(128);
|
||||||
|
entity.Property(x => x.UniqueNumber).HasMaxLength(128);
|
||||||
|
entity.Property(x => x.RejectionReason).HasMaxLength(256);
|
||||||
|
entity.Property(x => x.ProcessedAtUtc);
|
||||||
|
entity.HasIndex(x => x.ProcessedAtUtc);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,15 @@ namespace AVSCartonShipmentVerifier.Services;
|
||||||
|
|
||||||
public interface IScanHistoryStore
|
public interface IScanHistoryStore
|
||||||
{
|
{
|
||||||
Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default);
|
Task AddSuccessfulScanAsync(ScannedRecordDto record, CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyCollection<ScannedRecordDto>> GetLastFiveAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Task AddRejectedScanAsync(RejectedScanRecordDto record, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyCollection<ScannedRecordDto>> GetLastFiveSuccessfulScansAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyCollection<RejectedScanRecordDto>> GetLastFiveRejectedScansAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyCollection<ScannedRecordDto>> GetAllSuccessfulScansAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyCollection<RejectedScanRecordDto>> GetAllRejectedScansAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,67 @@ namespace AVSCartonShipmentVerifier.Services;
|
||||||
public sealed class InMemoryScanHistoryStore : IScanHistoryStore
|
public sealed class InMemoryScanHistoryStore : IScanHistoryStore
|
||||||
{
|
{
|
||||||
private const int MaxItems = 5;
|
private const int MaxItems = 5;
|
||||||
private readonly ConcurrentQueue<ScannedRecordDto> _records = new();
|
private readonly ConcurrentQueue<ScannedRecordDto> _successful = new();
|
||||||
|
private readonly ConcurrentQueue<RejectedScanRecordDto> _rejected = new();
|
||||||
private readonly Lock _syncLock = new();
|
private readonly Lock _syncLock = new();
|
||||||
|
|
||||||
public Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default)
|
public Task AddSuccessfulScanAsync(ScannedRecordDto record, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
lock (_syncLock)
|
lock (_syncLock)
|
||||||
{
|
{
|
||||||
_records.Enqueue(record);
|
_successful.Enqueue(record);
|
||||||
while (_records.Count > MaxItems)
|
while (_successful.Count > MaxItems)
|
||||||
{
|
{
|
||||||
_records.TryDequeue(out _);
|
_successful.TryDequeue(out _);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyCollection<ScannedRecordDto>> GetLastFiveAsync(CancellationToken cancellationToken = default)
|
public Task AddRejectedScanAsync(RejectedScanRecordDto record, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
lock (_syncLock)
|
lock (_syncLock)
|
||||||
{
|
{
|
||||||
return Task.FromResult<IReadOnlyCollection<ScannedRecordDto>>(_records.Reverse().ToArray());
|
_rejected.Enqueue(record);
|
||||||
}
|
while (_rejected.Count > MaxItems)
|
||||||
|
{
|
||||||
|
_rejected.TryDequeue(out _);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyCollection<ScannedRecordDto>> GetLastFiveSuccessfulScansAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_syncLock)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyCollection<ScannedRecordDto>>(_successful.Reverse().ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyCollection<RejectedScanRecordDto>> GetLastFiveRejectedScansAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_syncLock)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyCollection<RejectedScanRecordDto>>(_rejected.Reverse().ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyCollection<ScannedRecordDto>> GetAllSuccessfulScansAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_syncLock)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyCollection<ScannedRecordDto>>(_successful.Reverse().ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyCollection<RejectedScanRecordDto>> GetAllRejectedScansAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_syncLock)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyCollection<RejectedScanRecordDto>>(_rejected.Reverse().ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ public sealed class SqliteScanHistoryStore(
|
||||||
private readonly ScanHistoryOptions _options = options.Value;
|
private readonly ScanHistoryOptions _options = options.Value;
|
||||||
private readonly ILogger<SqliteScanHistoryStore> _logger = logger;
|
private readonly ILogger<SqliteScanHistoryStore> _logger = logger;
|
||||||
|
|
||||||
public async Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default)
|
public async Task AddSuccessfulScanAsync(ScannedRecordDto record, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -29,20 +29,43 @@ public sealed class SqliteScanHistoryStore(
|
||||||
});
|
});
|
||||||
|
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
await TrimOldRecordsAsync(cancellationToken);
|
await TrimSuccessfulAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
_logger.LogError(exception, "Failed to persist scan history entry.");
|
_logger.LogError(exception, "Failed to persist successful scan history entry.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyCollection<ScannedRecordDto>> GetLastFiveAsync(CancellationToken cancellationToken = default)
|
public async Task AddRejectedScanAsync(RejectedScanRecordDto record, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_dbContext.RejectedScanEntries.Add(new RejectedScanEntry
|
||||||
|
{
|
||||||
|
RawInputValue = record.RawInputValue,
|
||||||
|
ModelNumber = record.ModelNumber,
|
||||||
|
UniqueNumber = record.UniqueNumber,
|
||||||
|
RejectionReason = record.RejectionReason,
|
||||||
|
ProcessedAtUtc = record.ProcessedAtUtc
|
||||||
|
});
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
await TrimRejectedAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "Failed to persist rejected scan history entry.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyCollection<ScannedRecordDto>> GetLastFiveSuccessfulScansAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var entries = await _dbContext.ScanHistoryEntries
|
var entries = await _dbContext.ScanHistoryEntries
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Where(x => x.ShipmentMarked)
|
||||||
.OrderByDescending(x => x.ProcessedAtUtc)
|
.OrderByDescending(x => x.ProcessedAtUtc)
|
||||||
.Take(5)
|
.Take(5)
|
||||||
.ToArrayAsync(cancellationToken);
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
@ -60,12 +83,95 @@ public sealed class SqliteScanHistoryStore(
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
_logger.LogError(exception, "Failed to read scan history.");
|
_logger.LogError(exception, "Failed to read successful scan history.");
|
||||||
return Array.Empty<ScannedRecordDto>();
|
return Array.Empty<ScannedRecordDto>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TrimOldRecordsAsync(CancellationToken cancellationToken)
|
public async Task<IReadOnlyCollection<RejectedScanRecordDto>> GetLastFiveRejectedScansAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entries = await _dbContext.RejectedScanEntries
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(x => x.ProcessedAtUtc)
|
||||||
|
.Take(5)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.Select(x => new RejectedScanRecordDto
|
||||||
|
{
|
||||||
|
RawInputValue = x.RawInputValue,
|
||||||
|
ModelNumber = x.ModelNumber,
|
||||||
|
UniqueNumber = x.UniqueNumber,
|
||||||
|
RejectionReason = x.RejectionReason,
|
||||||
|
ProcessedAtUtc = DateTime.SpecifyKind(x.ProcessedAtUtc, DateTimeKind.Utc)
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "Failed to read rejected scan history.");
|
||||||
|
return Array.Empty<RejectedScanRecordDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyCollection<ScannedRecordDto>> GetAllSuccessfulScansAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entries = await _dbContext.ScanHistoryEntries
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.ShipmentMarked)
|
||||||
|
.OrderByDescending(x => x.ProcessedAtUtc)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.Select(x => new ScannedRecordDto
|
||||||
|
{
|
||||||
|
ModelNumber = x.ModelNumber,
|
||||||
|
UniqueNumber = x.UniqueNumber,
|
||||||
|
ExistsInSystem = x.ExistsInSystem,
|
||||||
|
ShipmentMarked = x.ShipmentMarked,
|
||||||
|
ProcessedAtUtc = DateTime.SpecifyKind(x.ProcessedAtUtc, DateTimeKind.Utc)
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "Failed to read full successful scan history.");
|
||||||
|
return Array.Empty<ScannedRecordDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyCollection<RejectedScanRecordDto>> GetAllRejectedScansAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entries = await _dbContext.RejectedScanEntries
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(x => x.ProcessedAtUtc)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.Select(x => new RejectedScanRecordDto
|
||||||
|
{
|
||||||
|
RawInputValue = x.RawInputValue,
|
||||||
|
ModelNumber = x.ModelNumber,
|
||||||
|
UniqueNumber = x.UniqueNumber,
|
||||||
|
RejectionReason = x.RejectionReason,
|
||||||
|
ProcessedAtUtc = DateTime.SpecifyKind(x.ProcessedAtUtc, DateTimeKind.Utc)
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "Failed to read full rejected scan history.");
|
||||||
|
return Array.Empty<RejectedScanRecordDto>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TrimSuccessfulAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var max = _options.MaxRecordsToKeep;
|
var max = _options.MaxRecordsToKeep;
|
||||||
if (max <= 0)
|
if (max <= 0)
|
||||||
|
|
@ -87,4 +193,27 @@ public sealed class SqliteScanHistoryStore(
|
||||||
_dbContext.ScanHistoryEntries.RemoveRange(toDelete.Select(id => new ScanHistoryEntry { Id = id }));
|
_dbContext.ScanHistoryEntries.RemoveRange(toDelete.Select(id => new ScanHistoryEntry { Id = id }));
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task TrimRejectedAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var max = _options.MaxRecordsToKeep;
|
||||||
|
if (max <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var toDelete = await _dbContext.RejectedScanEntries
|
||||||
|
.OrderByDescending(x => x.ProcessedAtUtc)
|
||||||
|
.Skip(max)
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (toDelete.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dbContext.RejectedScanEntries.RemoveRange(toDelete.Select(id => new RejectedScanEntry { Id = id }));
|
||||||
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ namespace AVSCartonShipmentVerifier.ViewModels;
|
||||||
|
|
||||||
public sealed class VerificationDashboardViewModel
|
public sealed class VerificationDashboardViewModel
|
||||||
{
|
{
|
||||||
public IReadOnlyCollection<ScannedRecordDto> RecentScans { get; init; } = [];
|
public IReadOnlyCollection<ScannedRecordDto> RecentSuccessfulScans { get; init; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyCollection<RejectedScanRecordDto> RecentRejectedScans { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue