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
main
SYED MUSTUFA AHMED NAQVI 2026-05-06 17:56:40 +05:00
parent dbb9933174
commit 20482ec357
21 changed files with 263 additions and 86 deletions

View File

@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>

View File

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

View File

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

View File

@ -11,11 +11,11 @@ public sealed class HomeController(IScanHistoryStore scanHistoryStore) : Control
private readonly IScanHistoryStore _scanHistoryStore = scanHistoryStore;
[HttpGet]
public IActionResult Index()
public async Task<IActionResult> Index(CancellationToken cancellationToken)
{
var model = new VerificationDashboardViewModel
{
RecentScans = _scanHistoryStore.GetLastFive()
RecentScans = await _scanHistoryStore.GetLastFiveAsync(cancellationToken)
};
return View(model);

View File

@ -29,7 +29,7 @@ public sealed class VerificationController(
return Ok(new
{
result = verificationResult,
recentScans = _scanHistoryStore.GetLastFive()
recentScans = await _scanHistoryStore.GetLastFiveAsync(cancellationToken)
});
}
catch (OperationCanceledException)

View File

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

View File

@ -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<ScanHistoryOptions>()
.Bind(builder.Configuration.GetSection(ScanHistoryOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddControllersWithViews();
builder.Services.AddSingleton<IScanHistoryStore, InMemoryScanHistoryStore>();
builder.Services.AddDbContext<ScanHistoryDbContext>((serviceProvider, options) =>
{
var scanHistoryOptions = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<ScanHistoryOptions>>().Value;
var connectionString = ScanHistoryPath.EnsureDirectoryAndGetConnectionString(scanHistoryOptions);
options.UseSqlite(connectionString);
});
builder.Services.AddScoped<IScanHistoryStore, SqliteScanHistoryStore>();
builder.Services.AddScoped<IVerificationDbConnectionFactory, VerificationDbConnectionFactory>();
builder.Services.AddScoped<ICartonVerificationRepository, CartonVerificationRepository>();
builder.Services.AddScoped<IShipmentVerificationService, ShipmentVerificationService>();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ScanHistoryDbContext>();
db.Database.EnsureCreated();
}
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");

View File

@ -33,6 +33,23 @@ public sealed class CartonVerificationRepository(
return result is not null;
}
public async Task<bool> 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))

View File

@ -3,5 +3,6 @@ namespace AVSCartonShipmentVerifier.Repositories;
public interface ICartonVerificationRepository
{
Task<bool> UniqueNumberExistsAsync(string uniqueNumber, CancellationToken cancellationToken = default);
Task<bool> MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
namespace AVSCartonShipmentVerifier.ScanHistory;
public sealed class ScanHistoryDbContext(DbContextOptions<ScanHistoryDbContext> options) : DbContext(options)
{
public DbSet<ScanHistoryEntry> ScanHistoryEntries => Set<ScanHistoryEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ScanHistoryEntry>(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);
});
}
}

View File

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

View File

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

View File

@ -4,7 +4,7 @@ namespace AVSCartonShipmentVerifier.Services;
public interface IScanHistoryStore
{
void Add(ScannedRecordDto record);
IReadOnlyCollection<ScannedRecordDto> GetLastFive();
Task AddAsync(ScannedRecordDto record, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<ScannedRecordDto>> GetLastFiveAsync(CancellationToken cancellationToken = default);
}

View File

@ -9,7 +9,7 @@ public sealed class InMemoryScanHistoryStore : IScanHistoryStore
private readonly ConcurrentQueue<ScannedRecordDto> _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<ScannedRecordDto> GetLastFive()
public Task<IReadOnlyCollection<ScannedRecordDto>> GetLastFiveAsync(CancellationToken cancellationToken = default)
{
lock (_syncLock)
{
return _records.Reverse().ToArray();
return Task.FromResult<IReadOnlyCollection<ScannedRecordDto>>(_records.Reverse().ToArray());
}
}
}

View File

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

View File

@ -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<ScanHistoryOptions> options,
ILogger<SqliteScanHistoryStore> logger) : IScanHistoryStore
{
private readonly ScanHistoryDbContext _dbContext = dbContext;
private readonly ScanHistoryOptions _options = options.Value;
private readonly ILogger<SqliteScanHistoryStore> _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<IReadOnlyCollection<ScannedRecordDto>> 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<ScannedRecordDto>();
}
}
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);
}
}

View File

@ -6,7 +6,7 @@
<header class="topbar">
<div class="topbar-brand">
<span class="brand-icon">&#x25C8;</span>
<span>AVS CARTON SHIPMENT VERIFIER</span>
<span>SHIPPING MARK</span>
</div>
<div class="topbar-siren" id="siren-indicator">
<span>&#128266;</span>
@ -47,12 +47,6 @@
</section>
</section>
<section class="panel panel-center-status status-neutral" id="status-center-card">
<div class="status-visual" id="status-visual">-</div>
<div class="status-headline" id="status-headline">AWAITING SCAN</div>
<div id="status-text" class="status-message">Scan a QR code to verify carton details</div>
</section>
<section class="right-column">
<section class="panel">
<h2 class="section-title"><span class="section-icon">&#9432;</span> SCANNED DETAILS</h2>
@ -103,7 +97,7 @@
@(item.ShipmentMarked ? "TRUE" : "FALSE")
</span>
</td>
<td>@item.ProcessedAtUtc.ToString("dd MMM yyyy h:mm tt")</td>
<td>@DateTime.SpecifyKind(item.ProcessedAtUtc, DateTimeKind.Utc).ToLocalTime().ToString("dd MMM yyyy h:mm tt")</td>
<td>System</td>
</tr>
rowNumber++;
@ -122,7 +116,7 @@
<footer class="page-footer">
<span>AVS C# .NET Application</span>
<span>|</span>
<span>AVS Carton Shipment Verifier</span>
<span>SHIPPING MARK</span>
</footer>
@section Scripts {

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - AVS Carton Shipment Verifier</title>
<title>@ViewData["Title"] - SHIPPING MARK</title>
<script type="importmap"></script>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />

View File

@ -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": {

View File

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

View File

@ -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,15 +25,35 @@
}
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;
}
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 {
@ -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) {