From 20482ec357bbb717b5147d3947d1b11e2d163ed8 Mon Sep 17 00:00:00 2001 From: Syed Mustafa Ahmed Naqvi Date: Wed, 6 May 2026 17:56:40 +0500 Subject: [PATCH] Persist scan history and mark verified shipments - Add SQLite-backed scan history storage with configurable database path and retention limit - Register scan history DbContext and persist recent scan records across app restarts - Update shipment verification flow to mark matched cartons as moved to warehouse - Treat verification as successful only when the record exists and the shipment flag update succeeds - Rename verification flag configuration to use moved_to_warehouse - Make scan history APIs async and update controllers/services accordingly - Improve dashboard scan handling for pasted QR values and prevent duplicate submissions - Update UI branding to SHIPPING MARK and simplify the dashboard status layout - Display scan history timestamps in local time --- AVSCartonShipmentVerifier.csproj | 1 + Configuration/CartonVerificationOptions.cs | 2 +- Configuration/ScanHistoryOptions.cs | 14 +++ Controllers/HomeController.cs | 4 +- Controllers/VerificationController.cs | 2 +- DTOs/VerificationResultDto.cs | 2 +- Program.cs | 24 ++++- Repositories/CartonVerificationRepository.cs | 17 ++++ Repositories/ICartonVerificationRepository.cs | 1 + ScanHistory/ScanHistoryDbContext.cs | 21 +++++ ScanHistory/ScanHistoryEntry.cs | 11 +++ ScanHistory/ScanHistoryPath.cs | 19 ++++ Services/IScanHistoryStore.cs | 4 +- Services/InMemoryScanHistoryStore.cs | 8 +- Services/ShipmentVerificationService.cs | 21 ++++- Services/SqliteScanHistoryStore.cs | 90 +++++++++++++++++++ Views/Home/Index.cshtml | 12 +-- Views/Shared/_Layout.cshtml | 2 +- appsettings.json | 8 +- wwwroot/css/site.css | 44 +-------- wwwroot/js/verification-dashboard.js | 42 +++++---- 21 files changed, 263 insertions(+), 86 deletions(-) create mode 100644 Configuration/ScanHistoryOptions.cs create mode 100644 ScanHistory/ScanHistoryDbContext.cs create mode 100644 ScanHistory/ScanHistoryEntry.cs create mode 100644 ScanHistory/ScanHistoryPath.cs create mode 100644 Services/SqliteScanHistoryStore.cs diff --git a/AVSCartonShipmentVerifier.csproj b/AVSCartonShipmentVerifier.csproj index e5a9444..0e54e59 100644 --- a/AVSCartonShipmentVerifier.csproj +++ b/AVSCartonShipmentVerifier.csproj @@ -9,6 +9,7 @@ + diff --git a/Configuration/CartonVerificationOptions.cs b/Configuration/CartonVerificationOptions.cs index 78a20bd..db5b239 100644 --- a/Configuration/CartonVerificationOptions.cs +++ b/Configuration/CartonVerificationOptions.cs @@ -13,6 +13,6 @@ public sealed class CartonVerificationOptions public string QrColumnName { get; init; } = "qr"; [Required] - public string ShipmentVerificationFlagColumnName { get; init; } = "shipment_verification_flag"; + public string MovedToWarehouseColumnName { get; init; } = "moved_to_warehouse"; } diff --git a/Configuration/ScanHistoryOptions.cs b/Configuration/ScanHistoryOptions.cs new file mode 100644 index 0000000..ad5e40b --- /dev/null +++ b/Configuration/ScanHistoryOptions.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace AVSCartonShipmentVerifier.Configuration; + +public sealed class ScanHistoryOptions +{ + public const string SectionName = "ScanHistory"; + + [Required] + public string DatabasePath { get; init; } = @"C:\Users\Public\AVSCartonShipmentVerifier\scan-history.db"; + + [Range(1, 500)] + public int MaxRecordsToKeep { get; init; } = 500; +} diff --git a/Controllers/HomeController.cs b/Controllers/HomeController.cs index d1772e9..cfbe9e7 100644 --- a/Controllers/HomeController.cs +++ b/Controllers/HomeController.cs @@ -11,11 +11,11 @@ public sealed class HomeController(IScanHistoryStore scanHistoryStore) : Control private readonly IScanHistoryStore _scanHistoryStore = scanHistoryStore; [HttpGet] - public IActionResult Index() + public async Task Index(CancellationToken cancellationToken) { var model = new VerificationDashboardViewModel { - RecentScans = _scanHistoryStore.GetLastFive() + RecentScans = await _scanHistoryStore.GetLastFiveAsync(cancellationToken) }; return View(model); diff --git a/Controllers/VerificationController.cs b/Controllers/VerificationController.cs index 43a2d8b..43fa5a7 100644 --- a/Controllers/VerificationController.cs +++ b/Controllers/VerificationController.cs @@ -29,7 +29,7 @@ public sealed class VerificationController( return Ok(new { result = verificationResult, - recentScans = _scanHistoryStore.GetLastFive() + recentScans = await _scanHistoryStore.GetLastFiveAsync(cancellationToken) }); } catch (OperationCanceledException) diff --git a/DTOs/VerificationResultDto.cs b/DTOs/VerificationResultDto.cs index 12f79c4..46c0f35 100644 --- a/DTOs/VerificationResultDto.cs +++ b/DTOs/VerificationResultDto.cs @@ -7,7 +7,7 @@ public sealed class VerificationResultDto public string RawQrValue { get; init; } = string.Empty; public bool ExistsInSystem { get; init; } public bool ShipmentMarked { get; init; } - public bool IsSuccessful => ExistsInSystem; + public bool IsSuccessful => ExistsInSystem && ShipmentMarked; public string Message { get; init; } = string.Empty; public DateTime ProcessedAtUtc { get; init; } } diff --git a/Program.cs b/Program.cs index 65b7bbc..2e6167b 100644 --- a/Program.cs +++ b/Program.cs @@ -1,7 +1,9 @@ using AVSCartonShipmentVerifier.Configuration; using AVSCartonShipmentVerifier.Data; using AVSCartonShipmentVerifier.Repositories; +using AVSCartonShipmentVerifier.ScanHistory; using AVSCartonShipmentVerifier.Services; +using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -11,14 +13,34 @@ builder.Services .ValidateDataAnnotations() .ValidateOnStart(); +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(ScanHistoryOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + builder.Services.AddControllersWithViews(); -builder.Services.AddSingleton(); + +builder.Services.AddDbContext((serviceProvider, options) => +{ + var scanHistoryOptions = serviceProvider.GetRequiredService>().Value; + var connectionString = ScanHistoryPath.EnsureDirectoryAndGetConnectionString(scanHistoryOptions); + options.UseSqlite(connectionString); +}); + +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); var app = builder.Build(); +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); +} + if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Home/Error"); diff --git a/Repositories/CartonVerificationRepository.cs b/Repositories/CartonVerificationRepository.cs index e30f49f..740b9a8 100644 --- a/Repositories/CartonVerificationRepository.cs +++ b/Repositories/CartonVerificationRepository.cs @@ -33,6 +33,23 @@ public sealed class CartonVerificationRepository( return result is not null; } + public async Task MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default) + { + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = $""" + UPDATE {QuoteIdentifier(_options.TableName)} + SET {QuoteIdentifier(_options.MovedToWarehouseColumnName)} = 1 + WHERE {QuoteIdentifier(_options.QrColumnName)} = @uniqueNumber; + """; + command.Parameters.AddWithValue("@uniqueNumber", uniqueNumber); + + var affectedRows = await command.ExecuteNonQueryAsync(cancellationToken); + return affectedRows > 0; + } + private static string QuoteIdentifier(string identifier) { if (!SqlIdentifierRegex.IsMatch(identifier)) diff --git a/Repositories/ICartonVerificationRepository.cs b/Repositories/ICartonVerificationRepository.cs index 56ec0e9..d5738e7 100644 --- a/Repositories/ICartonVerificationRepository.cs +++ b/Repositories/ICartonVerificationRepository.cs @@ -3,5 +3,6 @@ namespace AVSCartonShipmentVerifier.Repositories; public interface ICartonVerificationRepository { Task UniqueNumberExistsAsync(string uniqueNumber, CancellationToken cancellationToken = default); + Task MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default); } diff --git a/ScanHistory/ScanHistoryDbContext.cs b/ScanHistory/ScanHistoryDbContext.cs new file mode 100644 index 0000000..55e4751 --- /dev/null +++ b/ScanHistory/ScanHistoryDbContext.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; + +namespace AVSCartonShipmentVerifier.ScanHistory; + +public sealed class ScanHistoryDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet ScanHistoryEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("scan_history_entries"); + entity.HasKey(x => x.Id); + entity.Property(x => x.ModelNumber).HasMaxLength(128); + entity.Property(x => x.UniqueNumber).HasMaxLength(128); + entity.Property(x => x.ProcessedAtUtc); + entity.HasIndex(x => x.ProcessedAtUtc); + }); + } +} diff --git a/ScanHistory/ScanHistoryEntry.cs b/ScanHistory/ScanHistoryEntry.cs new file mode 100644 index 0000000..f250605 --- /dev/null +++ b/ScanHistory/ScanHistoryEntry.cs @@ -0,0 +1,11 @@ +namespace AVSCartonShipmentVerifier.ScanHistory; + +public sealed class ScanHistoryEntry +{ + public long Id { get; set; } + public string ModelNumber { get; set; } = string.Empty; + public string UniqueNumber { get; set; } = string.Empty; + public bool ExistsInSystem { get; set; } + public bool ShipmentMarked { get; set; } + public DateTime ProcessedAtUtc { get; set; } +} diff --git a/ScanHistory/ScanHistoryPath.cs b/ScanHistory/ScanHistoryPath.cs new file mode 100644 index 0000000..7cf1d36 --- /dev/null +++ b/ScanHistory/ScanHistoryPath.cs @@ -0,0 +1,19 @@ +using System.IO; +using AVSCartonShipmentVerifier.Configuration; + +namespace AVSCartonShipmentVerifier.ScanHistory; + +public static class ScanHistoryPath +{ + public static string EnsureDirectoryAndGetConnectionString(ScanHistoryOptions options) + { + var fullPath = Path.GetFullPath(options.DatabasePath); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + return $"Data Source={fullPath}"; + } +} diff --git a/Services/IScanHistoryStore.cs b/Services/IScanHistoryStore.cs index e5a5054..07ba848 100644 --- a/Services/IScanHistoryStore.cs +++ b/Services/IScanHistoryStore.cs @@ -4,7 +4,7 @@ namespace AVSCartonShipmentVerifier.Services; public interface IScanHistoryStore { - void Add(ScannedRecordDto record); - IReadOnlyCollection GetLastFive(); + Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default); + Task> GetLastFiveAsync(CancellationToken cancellationToken = default); } diff --git a/Services/InMemoryScanHistoryStore.cs b/Services/InMemoryScanHistoryStore.cs index ddb389e..2b65c8f 100644 --- a/Services/InMemoryScanHistoryStore.cs +++ b/Services/InMemoryScanHistoryStore.cs @@ -9,7 +9,7 @@ public sealed class InMemoryScanHistoryStore : IScanHistoryStore private readonly ConcurrentQueue _records = new(); private readonly Lock _syncLock = new(); - public void Add(ScannedRecordDto record) + public Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default) { lock (_syncLock) { @@ -19,13 +19,15 @@ public sealed class InMemoryScanHistoryStore : IScanHistoryStore _records.TryDequeue(out _); } } + + return Task.CompletedTask; } - public IReadOnlyCollection GetLastFive() + public Task> GetLastFiveAsync(CancellationToken cancellationToken = default) { lock (_syncLock) { - return _records.Reverse().ToArray(); + return Task.FromResult>(_records.Reverse().ToArray()); } } } diff --git a/Services/ShipmentVerificationService.cs b/Services/ShipmentVerificationService.cs index 1cd0924..b2ec63c 100644 --- a/Services/ShipmentVerificationService.cs +++ b/Services/ShipmentVerificationService.cs @@ -45,22 +45,35 @@ public sealed class ShipmentVerificationService( ProcessedAtUtc = nowUtc }; - _historyStore.Add(ToScannedRecord(missingResult)); + await _historyStore.AddAsync(ToScannedRecord(missingResult), cancellationToken); return missingResult; } + bool marked; + try + { + marked = await _repository.MarkShipmentVerifiedAsync(parseResult.UniqueNumber, cancellationToken); + } + catch (Exception exception) + { + _logger.LogError(exception, "Failed to mark shipment verified for unique number {UniqueNumber}.", parseResult.UniqueNumber); + marked = false; + } + var result = new VerificationResultDto { ModelNumber = parseResult.ModelNumber, UniqueNumber = parseResult.UniqueNumber, RawQrValue = rawQrValue, ExistsInSystem = true, - ShipmentMarked = true, - Message = "Record found in carton verification log.", + ShipmentMarked = marked, + Message = marked + ? "Record found and shipment marked successfully." + : "Record found, but shipment could not be marked.", ProcessedAtUtc = nowUtc }; - _historyStore.Add(ToScannedRecord(result)); + await _historyStore.AddAsync(ToScannedRecord(result), cancellationToken); return result; } diff --git a/Services/SqliteScanHistoryStore.cs b/Services/SqliteScanHistoryStore.cs new file mode 100644 index 0000000..7597254 --- /dev/null +++ b/Services/SqliteScanHistoryStore.cs @@ -0,0 +1,90 @@ +using AVSCartonShipmentVerifier.DTOs; +using AVSCartonShipmentVerifier.Configuration; +using AVSCartonShipmentVerifier.ScanHistory; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace AVSCartonShipmentVerifier.Services; + +public sealed class SqliteScanHistoryStore( + ScanHistoryDbContext dbContext, + IOptions options, + ILogger logger) : IScanHistoryStore +{ + private readonly ScanHistoryDbContext _dbContext = dbContext; + private readonly ScanHistoryOptions _options = options.Value; + private readonly ILogger _logger = logger; + + public async Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default) + { + try + { + _dbContext.ScanHistoryEntries.Add(new ScanHistoryEntry + { + ModelNumber = record.ModelNumber, + UniqueNumber = record.UniqueNumber, + ExistsInSystem = record.ExistsInSystem, + ShipmentMarked = record.ShipmentMarked, + ProcessedAtUtc = record.ProcessedAtUtc + }); + + await _dbContext.SaveChangesAsync(cancellationToken); + await TrimOldRecordsAsync(cancellationToken); + } + catch (Exception exception) + { + _logger.LogError(exception, "Failed to persist scan history entry."); + } + } + + public async Task> GetLastFiveAsync(CancellationToken cancellationToken = default) + { + try + { + var entries = await _dbContext.ScanHistoryEntries + .AsNoTracking() + .OrderByDescending(x => x.ProcessedAtUtc) + .Take(5) + .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 scan history."); + return Array.Empty(); + } + } + + private async Task TrimOldRecordsAsync(CancellationToken cancellationToken) + { + var max = _options.MaxRecordsToKeep; + if (max <= 0) + { + return; + } + + var toDelete = await _dbContext.ScanHistoryEntries + .OrderByDescending(x => x.ProcessedAtUtc) + .Skip(max) + .Select(x => x.Id) + .ToArrayAsync(cancellationToken); + + if (toDelete.Length == 0) + { + return; + } + + _dbContext.ScanHistoryEntries.RemoveRange(toDelete.Select(id => new ScanHistoryEntry { Id = id })); + await _dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/Views/Home/Index.cshtml b/Views/Home/Index.cshtml index cd20512..417e097 100644 --- a/Views/Home/Index.cshtml +++ b/Views/Home/Index.cshtml @@ -6,7 +6,7 @@
- AVS CARTON SHIPMENT VERIFIER + SHIPPING MARK
🔊 @@ -47,12 +47,6 @@ -
-
-
-
AWAITING SCAN
-
Scan a QR code to verify carton details
-
-

SCANNED DETAILS

@@ -103,7 +97,7 @@ @(item.ShipmentMarked ? "TRUE" : "FALSE") - @item.ProcessedAtUtc.ToString("dd MMM yyyy h:mm tt") + @DateTime.SpecifyKind(item.ProcessedAtUtc, DateTimeKind.Utc).ToLocalTime().ToString("dd MMM yyyy h:mm tt") System rowNumber++; @@ -122,7 +116,7 @@
AVS C# .NET Application | - AVS Carton Shipment Verifier + SHIPPING MARK
@section Scripts { diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml index 71e4d65..7646d25 100644 --- a/Views/Shared/_Layout.cshtml +++ b/Views/Shared/_Layout.cshtml @@ -3,7 +3,7 @@ - @ViewData["Title"] - AVS Carton Shipment Verifier + @ViewData["Title"] - SHIPPING MARK diff --git a/appsettings.json b/appsettings.json index 3673401..8c200c6 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,11 +1,15 @@ { "ConnectionStrings": { - "VerificationDatabase": "Server=utopia-industries-rr.c5qech8o9lgg.us-east-1.rds.amazonaws.com;Port=3306;Database=item_verification_system;User ID=muhammad.faique;Password=21)3lq6b!A@.;SslMode=Preferred;Allow User Variables=True;" + "VerificationDatabase": "" }, "CartonVerification": { "TableName": "carton_verification_log", "QrColumnName": "qr", - "ShipmentVerificationFlagColumnName": "shipment_verification_flag" + "MovedToWarehouseColumnName": "moved_to_warehouse" + }, + "ScanHistory": { + "DatabasePath": "C:\\Users\\Public\\AVSCartonShipmentVerifier\\scan-history.db", + "MaxRecordsToKeep": 500 }, "Logging": { "LogLevel": { diff --git a/wwwroot/css/site.css b/wwwroot/css/site.css index 8b9f0af..95320f3 100644 --- a/wwwroot/css/site.css +++ b/wwwroot/css/site.css @@ -58,7 +58,7 @@ body { .top-grid { display: grid; - grid-template-columns: 1.15fr 0.75fr 1.1fr; + grid-template-columns: 1fr 1fr; gap: 10px; } @@ -164,56 +164,18 @@ body { color: #4f6687; } -.panel-center-status { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - min-height: 314px; -} - -.status-visual { - width: 94px; - height: 94px; - border-radius: 50%; - background: #8b9cb8; - color: #fff; - display: grid; - place-items: center; - font-size: 2.4rem; - font-weight: 800; - margin-bottom: 10px; -} - -.status-headline { - color: #123d73; - font-size: 2.6rem; - font-weight: 800; -} - -.status-message { - margin-top: 6px; - color: #5f7392; - font-size: 1.2rem; -} - -.status-success .status-visual, .status-success .status-mini-icon { background: #3cae58; } -.status-failed .status-visual, .status-failed .status-mini-icon { background: #d54d56; } -.status-success .status-headline, .status-success .status-mini-headline { color: #2f9950; } -.status-failed .status-headline, .status-failed .status-mini-headline { color: #bf3340; } @@ -347,10 +309,6 @@ body { grid-template-columns: 1fr; } - .panel-center-status { - min-height: 220px; - } - .info-grid { grid-template-columns: 1fr; } diff --git a/wwwroot/js/verification-dashboard.js b/wwwroot/js/verification-dashboard.js index 6b03a64..e742bae 100644 --- a/wwwroot/js/verification-dashboard.js +++ b/wwwroot/js/verification-dashboard.js @@ -1,12 +1,8 @@ (() => { const qrInput = document.getElementById("qr-input"); const statusCard = document.getElementById("status-card"); - const statusCenterCard = document.getElementById("status-center-card"); - const statusVisual = document.getElementById("status-visual"); const statusMiniIcon = document.getElementById("status-mini-icon"); - const statusHeadline = document.getElementById("status-headline"); const statusMiniHeadline = document.getElementById("status-mini-headline"); - const statusText = document.getElementById("status-text"); const statusMiniMessage = document.getElementById("status-mini-message"); const lastUpdatedTime = document.getElementById("last-updated-time"); const historyBody = document.querySelector("#history-table tbody"); @@ -21,6 +17,7 @@ }; const antiForgeryToken = document.querySelector('input[name="__RequestVerificationToken"]')?.value; + let isSubmitting = false; qrInput.addEventListener("keydown", async event => { if (event.key !== "Enter") { @@ -28,16 +25,36 @@ } event.preventDefault(); - const qrValue = qrInput.value.trim(); - if (!qrValue) { + await submitCurrentInput(); + }); + + qrInput.addEventListener("paste", async event => { + const pastedValue = event.clipboardData?.getData("text")?.trim(); + if (!pastedValue) { return; } - await submitScan(qrValue); - qrInput.value = ""; - qrInput.focus(); + event.preventDefault(); + qrInput.value = pastedValue; + await submitCurrentInput(); }); + async function submitCurrentInput() { + const qrValue = qrInput.value.trim(); + if (!qrValue || isSubmitting) { + return; + } + + isSubmitting = true; + try { + await submitScan(qrValue); + qrInput.value = ""; + qrInput.focus(); + } finally { + isSubmitting = false; + } + } + async function submitScan(qrValue) { try { const response = await fetch("/api/verification/scan", { @@ -72,11 +89,8 @@ function renderStatus(result) { if (result.isSuccessful) { setStatusClasses("status-success"); - statusVisual.textContent = "\u2713"; statusMiniIcon.textContent = "\u2713"; - statusHeadline.textContent = "VERIFIED"; statusMiniHeadline.textContent = "VERIFIED"; - statusText.textContent = result.message; statusMiniMessage.textContent = result.message; lastUpdatedTime.textContent = formatDisplayTime(result.processedAtUtc); return; @@ -88,18 +102,14 @@ function setFailedState(message) { setStatusClasses("status-failed"); - statusVisual.textContent = "!"; statusMiniIcon.textContent = "!"; - statusHeadline.textContent = "FAILED"; statusMiniHeadline.textContent = "FAILED"; - statusText.textContent = message; statusMiniMessage.textContent = message; lastUpdatedTime.textContent = "-"; } function setStatusClasses(statusClass) { statusCard.className = `panel panel-status ${statusClass}`; - statusCenterCard.className = `panel panel-center-status ${statusClass}`; } function renderDetails(result) {