From 1ffe45c76b24be3dae908c4f581b39d567cd94f3 Mon Sep 17 00:00:00 2001 From: Syed Mustafa Ahmed Naqvi Date: Fri, 15 May 2026 11:42:32 +0500 Subject: [PATCH] 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. --- ScanHistory/RejectedScanEntry.cs | 11 ++ ScanHistory/ScanHistoryDbContext.cs | 14 ++ Services/IScanHistoryStore.cs | 14 +- Services/InMemoryScanHistoryStore.cs | 54 +++++-- Services/SqliteScanHistoryStore.cs | 141 ++++++++++++++++++- ViewModels/VerificationDashboardViewModel.cs | 4 +- 6 files changed, 220 insertions(+), 18 deletions(-) create mode 100644 ScanHistory/RejectedScanEntry.cs diff --git a/ScanHistory/RejectedScanEntry.cs b/ScanHistory/RejectedScanEntry.cs new file mode 100644 index 0000000..65a8e49 --- /dev/null +++ b/ScanHistory/RejectedScanEntry.cs @@ -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; } +} diff --git a/ScanHistory/ScanHistoryDbContext.cs b/ScanHistory/ScanHistoryDbContext.cs index 55e4751..79569c9 100644 --- a/ScanHistory/ScanHistoryDbContext.cs +++ b/ScanHistory/ScanHistoryDbContext.cs @@ -6,6 +6,8 @@ public sealed class ScanHistoryDbContext(DbContextOptions { public DbSet ScanHistoryEntries => Set(); + public DbSet RejectedScanEntries => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => @@ -17,5 +19,17 @@ public sealed class ScanHistoryDbContext(DbContextOptions entity.Property(x => x.ProcessedAtUtc); entity.HasIndex(x => x.ProcessedAtUtc); }); + + modelBuilder.Entity(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); + }); } } diff --git a/Services/IScanHistoryStore.cs b/Services/IScanHistoryStore.cs index 07ba848..269a139 100644 --- a/Services/IScanHistoryStore.cs +++ b/Services/IScanHistoryStore.cs @@ -4,7 +4,15 @@ namespace AVSCartonShipmentVerifier.Services; public interface IScanHistoryStore { - Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default); - Task> GetLastFiveAsync(CancellationToken cancellationToken = default); -} + Task AddSuccessfulScanAsync(ScannedRecordDto record, CancellationToken cancellationToken = default); + Task AddRejectedScanAsync(RejectedScanRecordDto record, CancellationToken cancellationToken = default); + + Task> GetLastFiveSuccessfulScansAsync(CancellationToken cancellationToken = default); + + Task> GetLastFiveRejectedScansAsync(CancellationToken cancellationToken = default); + + Task> GetAllSuccessfulScansAsync(CancellationToken cancellationToken = default); + + Task> GetAllRejectedScansAsync(CancellationToken cancellationToken = default); +} diff --git a/Services/InMemoryScanHistoryStore.cs b/Services/InMemoryScanHistoryStore.cs index 2b65c8f..15a60b1 100644 --- a/Services/InMemoryScanHistoryStore.cs +++ b/Services/InMemoryScanHistoryStore.cs @@ -6,29 +6,67 @@ namespace AVSCartonShipmentVerifier.Services; public sealed class InMemoryScanHistoryStore : IScanHistoryStore { private const int MaxItems = 5; - private readonly ConcurrentQueue _records = new(); + private readonly ConcurrentQueue _successful = new(); + private readonly ConcurrentQueue _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> GetLastFiveAsync(CancellationToken cancellationToken = default) + public Task AddRejectedScanAsync(RejectedScanRecordDto record, CancellationToken cancellationToken = default) { lock (_syncLock) { - return Task.FromResult>(_records.Reverse().ToArray()); + _rejected.Enqueue(record); + while (_rejected.Count > MaxItems) + { + _rejected.TryDequeue(out _); + } + } + + return Task.CompletedTask; + } + + public Task> GetLastFiveSuccessfulScansAsync(CancellationToken cancellationToken = default) + { + lock (_syncLock) + { + return Task.FromResult>(_successful.Reverse().ToArray()); + } + } + + public Task> GetLastFiveRejectedScansAsync(CancellationToken cancellationToken = default) + { + lock (_syncLock) + { + return Task.FromResult>(_rejected.Reverse().ToArray()); + } + } + + public Task> GetAllSuccessfulScansAsync(CancellationToken cancellationToken = default) + { + lock (_syncLock) + { + return Task.FromResult>(_successful.Reverse().ToArray()); + } + } + + public Task> GetAllRejectedScansAsync(CancellationToken cancellationToken = default) + { + lock (_syncLock) + { + return Task.FromResult>(_rejected.Reverse().ToArray()); } } } - diff --git a/Services/SqliteScanHistoryStore.cs b/Services/SqliteScanHistoryStore.cs index 7597254..450c258 100644 --- a/Services/SqliteScanHistoryStore.cs +++ b/Services/SqliteScanHistoryStore.cs @@ -15,7 +15,7 @@ public sealed class SqliteScanHistoryStore( private readonly ScanHistoryOptions _options = options.Value; private readonly ILogger _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> 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> 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(); } } - private async Task TrimOldRecordsAsync(CancellationToken cancellationToken) + public async Task> 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(); + } + } + + public async Task> 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(); + } + } + + public async Task> 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(); + } + } + + 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); + } } diff --git a/ViewModels/VerificationDashboardViewModel.cs b/ViewModels/VerificationDashboardViewModel.cs index cbe9e41..dbd0f13 100644 --- a/ViewModels/VerificationDashboardViewModel.cs +++ b/ViewModels/VerificationDashboardViewModel.cs @@ -4,6 +4,8 @@ namespace AVSCartonShipmentVerifier.ViewModels; public sealed class VerificationDashboardViewModel { - public IReadOnlyCollection RecentScans { get; init; } = []; + public IReadOnlyCollection RecentSuccessfulScans { get; init; } = []; + + public IReadOnlyCollection RecentRejectedScans { get; init; } = []; }