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<RejectedScanEntry> RejectedScanEntries => Set<RejectedScanEntry>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ScanHistoryEntry>(entity =>
|
||||
|
|
@ -17,5 +19,17 @@ public sealed class ScanHistoryDbContext(DbContextOptions<ScanHistoryDbContext>
|
|||
entity.Property(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
|
||||
{
|
||||
Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<ScannedRecordDto>> GetLastFiveAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
Task AddSuccessfulScanAsync(ScannedRecordDto record, 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
|
||||
{
|
||||
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();
|
||||
|
||||
public Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default)
|
||||
public Task AddSuccessfulScanAsync(ScannedRecordDto record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_syncLock)
|
||||
{
|
||||
_records.Enqueue(record);
|
||||
while (_records.Count > MaxItems)
|
||||
_successful.Enqueue(record);
|
||||
while (_successful.Count > MaxItems)
|
||||
{
|
||||
_records.TryDequeue(out _);
|
||||
_successful.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyCollection<ScannedRecordDto>> GetLastFiveAsync(CancellationToken cancellationToken = default)
|
||||
public Task AddRejectedScanAsync(RejectedScanRecordDto record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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 ILogger<SqliteScanHistoryStore> _logger = logger;
|
||||
|
||||
public async Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default)
|
||||
public async Task AddSuccessfulScanAsync(ScannedRecordDto record, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -29,20 +29,43 @@ public sealed class SqliteScanHistoryStore(
|
|||
});
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
await TrimOldRecordsAsync(cancellationToken);
|
||||
await TrimSuccessfulAsync(cancellationToken);
|
||||
}
|
||||
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
|
||||
{
|
||||
var entries = await _dbContext.ScanHistoryEntries
|
||||
.AsNoTracking()
|
||||
.Where(x => x.ShipmentMarked)
|
||||
.OrderByDescending(x => x.ProcessedAtUtc)
|
||||
.Take(5)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
|
@ -60,12 +83,95 @@ public sealed class SqliteScanHistoryStore(
|
|||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Failed to read scan history.");
|
||||
_logger.LogError(exception, "Failed to read successful scan history.");
|
||||
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;
|
||||
if (max <= 0)
|
||||
|
|
@ -87,4 +193,27 @@ public sealed class SqliteScanHistoryStore(
|
|||
_dbContext.ScanHistoryEntries.RemoveRange(toDelete.Select(id => new ScanHistoryEntry { Id = id }));
|
||||
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 IReadOnlyCollection<ScannedRecordDto> RecentScans { get; init; } = [];
|
||||
public IReadOnlyCollection<ScannedRecordDto> RecentSuccessfulScans { get; init; } = [];
|
||||
|
||||
public IReadOnlyCollection<RejectedScanRecordDto> RecentRejectedScans { get; init; } = [];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue