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
SYED MUSTUFA AHMED NAQVI 2026-05-15 11:42:32 +05:00
parent b26af6ef4b
commit 1ffe45c76b
6 changed files with 220 additions and 18 deletions

View File

@ -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; }
}

View File

@ -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);
});
}
}

View File

@ -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);
}

View File

@ -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());
}
}
}

View File

@ -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);
}
}

View File

@ -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; } = [];
}