From d7cfdd191d91233c62af4aa674d0e550d5c44d91 Mon Sep 17 00:00:00 2001 From: Syed Mustafa Ahmed Naqvi Date: Fri, 19 Jun 2026 11:47:30 +0500 Subject: [PATCH] Add moved-to-warehouse timestamp logging - Set moved_to_warehouse_at with UTC timestamp when a carton is first marked as moved - Preserve existing moved_to_warehouse update and duplicate-scan behavior - Return and store moved-to-warehouse timestamp in scan result/history models - Add SQLite history compatibility column for stored successful scans - Show "Moved To Warehouse At" in successful scan history UI, with "-" for null values - Add configurable moved_to_warehouse_at column mapping --- Configuration/CartonVerificationOptions.cs | 4 ++- DTOs/ScannedRecordDto.cs | 1 + DTOs/VerificationResultDto.cs | 2 +- Program.cs | 32 +++++++++++++++++ Repositories/CartonVerificationRepository.cs | 34 +++++++++++++++--- Repositories/ICartonVerificationRepository.cs | 3 +- Repositories/ShipmentMarkResult.cs | 3 ++ ScanHistory/ScanHistoryDbContext.cs | 1 + ScanHistory/ScanHistoryEntry.cs | 1 + Services/ShipmentVerificationService.cs | 9 +++-- Services/SqliteScanHistoryStore.cs | 4 +++ Views/Home/Index.cshtml | 2 ++ appsettings.json | 3 +- wwwroot/js/verification-dashboard.js | 36 +++++++++++++++++-- 14 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 Repositories/ShipmentMarkResult.cs diff --git a/Configuration/CartonVerificationOptions.cs b/Configuration/CartonVerificationOptions.cs index db5b239..656e90f 100644 --- a/Configuration/CartonVerificationOptions.cs +++ b/Configuration/CartonVerificationOptions.cs @@ -14,5 +14,7 @@ public sealed class CartonVerificationOptions [Required] public string MovedToWarehouseColumnName { get; init; } = "moved_to_warehouse"; -} + [Required] + public string MovedToWarehouseAtColumnName { get; init; } = "moved_to_warehouse_at"; +} diff --git a/DTOs/ScannedRecordDto.cs b/DTOs/ScannedRecordDto.cs index 76e85ed..1aa1e7d 100644 --- a/DTOs/ScannedRecordDto.cs +++ b/DTOs/ScannedRecordDto.cs @@ -6,6 +6,7 @@ public sealed class ScannedRecordDto public string UniqueNumber { get; init; } = string.Empty; public bool ExistsInSystem { get; init; } public bool ShipmentMarked { get; init; } + public DateTime? MovedToWarehouseAtUtc { get; init; } public DateTime ProcessedAtUtc { get; init; } } diff --git a/DTOs/VerificationResultDto.cs b/DTOs/VerificationResultDto.cs index 1916080..d03f847 100644 --- a/DTOs/VerificationResultDto.cs +++ b/DTOs/VerificationResultDto.cs @@ -7,6 +7,7 @@ public sealed class VerificationResultDto public string RawQrValue { get; init; } = string.Empty; public bool ExistsInSystem { get; init; } public bool ShipmentMarked { get; init; } + public DateTime? MovedToWarehouseAtUtc { get; init; } public bool IsSuccessful => ExistsInSystem && ShipmentMarked; public string Message { get; init; } = string.Empty; @@ -17,4 +18,3 @@ public sealed class VerificationResultDto public DateTime ProcessedAtUtc { get; init; } } - diff --git a/Program.cs b/Program.cs index b989a20..f904958 100644 --- a/Program.cs +++ b/Program.cs @@ -69,6 +69,38 @@ using (var scope = app.Services.CreateScope()) CREATE INDEX IF NOT EXISTS IX_rejected_scan_entries_ProcessedAtUtc ON rejected_scan_entries (ProcessedAtUtc); """); + + db.Database.OpenConnection(); + try + { + var hasMovedAtColumn = false; + using (var command = db.Database.GetDbConnection().CreateCommand()) + { + command.CommandText = "PRAGMA table_info(scan_history_entries);"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + if (string.Equals(reader.GetString(1), "MovedToWarehouseAtUtc", StringComparison.OrdinalIgnoreCase)) + { + hasMovedAtColumn = true; + break; + } + } + } + + if (!hasMovedAtColumn) + { + db.Database.ExecuteSqlRaw( + """ + ALTER TABLE scan_history_entries + ADD COLUMN MovedToWarehouseAtUtc TEXT NULL; + """); + } + } + finally + { + db.Database.CloseConnection(); + } } if (!app.Environment.IsDevelopment()) diff --git a/Repositories/CartonVerificationRepository.cs b/Repositories/CartonVerificationRepository.cs index c815ece..5c0be98 100644 --- a/Repositories/CartonVerificationRepository.cs +++ b/Repositories/CartonVerificationRepository.cs @@ -39,7 +39,7 @@ public sealed class CartonVerificationRepository( return moved != 0 ? CartonVerificationStatus.AlreadyTransferred : CartonVerificationStatus.PendingTransfer; } - public async Task MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default) + public async Task MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default) { await using var connection = _connectionFactory.CreateConnection(); await connection.OpenAsync(cancellationToken); @@ -47,14 +47,41 @@ public sealed class CartonVerificationRepository( await using var command = connection.CreateCommand(); command.CommandText = $""" UPDATE {QuoteIdentifier(_options.TableName)} - SET {QuoteIdentifier(_options.MovedToWarehouseColumnName)} = 1 + SET {QuoteIdentifier(_options.MovedToWarehouseColumnName)} = 1, + {QuoteIdentifier(_options.MovedToWarehouseAtColumnName)} = UTC_TIMESTAMP(6) WHERE {QuoteIdentifier(_options.QrColumnName)} = @uniqueNumber AND {QuoteIdentifier(_options.MovedToWarehouseColumnName)} = 0; """; command.Parameters.AddWithValue("@uniqueNumber", uniqueNumber); var affectedRows = await command.ExecuteNonQueryAsync(cancellationToken); - return affectedRows > 0; + if (affectedRows <= 0) + { + return new ShipmentMarkResult(false, null); + } + + await using var timestampCommand = connection.CreateCommand(); + timestampCommand.CommandText = $""" + SELECT {QuoteIdentifier(_options.MovedToWarehouseAtColumnName)} + FROM {QuoteIdentifier(_options.TableName)} + WHERE {QuoteIdentifier(_options.QrColumnName)} = @uniqueNumber + LIMIT 1; + """; + timestampCommand.Parameters.AddWithValue("@uniqueNumber", uniqueNumber); + + var timestamp = await timestampCommand.ExecuteScalarAsync(cancellationToken); + return new ShipmentMarkResult(true, ToNullableUtcDateTime(timestamp)); + } + + private static DateTime? ToNullableUtcDateTime(object? value) + { + if (value is null || value is DBNull) + { + return null; + } + + var dateTime = Convert.ToDateTime(value); + return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); } public async Task GetAvailableCartonCountAsync(CancellationToken cancellationToken = default) @@ -99,4 +126,3 @@ public sealed class CartonVerificationRepository( return $"`{identifier}`"; } } - diff --git a/Repositories/ICartonVerificationRepository.cs b/Repositories/ICartonVerificationRepository.cs index afb0f9a..0eaed13 100644 --- a/Repositories/ICartonVerificationRepository.cs +++ b/Repositories/ICartonVerificationRepository.cs @@ -4,10 +4,9 @@ public interface ICartonVerificationRepository { Task GetCartonStatusByUniqueNumberAsync(string uniqueNumber, CancellationToken cancellationToken = default); - Task MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default); + Task MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default); Task GetAvailableCartonCountAsync(CancellationToken cancellationToken = default); Task GetTotalMovedCartonCountAsync(CancellationToken cancellationToken = default); } - diff --git a/Repositories/ShipmentMarkResult.cs b/Repositories/ShipmentMarkResult.cs new file mode 100644 index 0000000..4cf2e9b --- /dev/null +++ b/Repositories/ShipmentMarkResult.cs @@ -0,0 +1,3 @@ +namespace AVSCartonShipmentVerifier.Repositories; + +public sealed record ShipmentMarkResult(bool Marked, DateTime? MovedToWarehouseAtUtc); diff --git a/ScanHistory/ScanHistoryDbContext.cs b/ScanHistory/ScanHistoryDbContext.cs index 79569c9..09caba3 100644 --- a/ScanHistory/ScanHistoryDbContext.cs +++ b/ScanHistory/ScanHistoryDbContext.cs @@ -16,6 +16,7 @@ public sealed class ScanHistoryDbContext(DbContextOptions entity.HasKey(x => x.Id); entity.Property(x => x.ModelNumber).HasMaxLength(128); entity.Property(x => x.UniqueNumber).HasMaxLength(128); + entity.Property(x => x.MovedToWarehouseAtUtc); entity.Property(x => x.ProcessedAtUtc); entity.HasIndex(x => x.ProcessedAtUtc); }); diff --git a/ScanHistory/ScanHistoryEntry.cs b/ScanHistory/ScanHistoryEntry.cs index f250605..b664977 100644 --- a/ScanHistory/ScanHistoryEntry.cs +++ b/ScanHistory/ScanHistoryEntry.cs @@ -7,5 +7,6 @@ public sealed class ScanHistoryEntry public string UniqueNumber { get; set; } = string.Empty; public bool ExistsInSystem { get; set; } public bool ShipmentMarked { get; set; } + public DateTime? MovedToWarehouseAtUtc { get; set; } public DateTime ProcessedAtUtc { get; set; } } diff --git a/Services/ShipmentVerificationService.cs b/Services/ShipmentVerificationService.cs index 6f1b47b..fc41450 100644 --- a/Services/ShipmentVerificationService.cs +++ b/Services/ShipmentVerificationService.cs @@ -65,10 +65,10 @@ public sealed class ShipmentVerificationService( nowUtc); case CartonVerificationStatus.PendingTransfer: - bool marked; + ShipmentMarkResult markResult; try { - marked = await _repository.MarkShipmentVerifiedAsync(parseResult.UniqueNumber, cancellationToken); + markResult = await _repository.MarkShipmentVerifiedAsync(parseResult.UniqueNumber, cancellationToken); } catch (Exception exception) { @@ -77,12 +77,13 @@ public sealed class ShipmentVerificationService( return Reject(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, existsInSystem: true, nowUtc); } - if (!marked) + if (!markResult.Marked) { await PersistRejectionAsync(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, nowUtc, cancellationToken); return Reject(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, existsInSystem: true, nowUtc); } + var movedToWarehouseAtUtc = markResult.MovedToWarehouseAtUtc ?? nowUtc; var success = new VerificationResultDto { ModelNumber = parseResult.ModelNumber, @@ -90,6 +91,7 @@ public sealed class ShipmentVerificationService( RawQrValue = raw, ExistsInSystem = true, ShipmentMarked = true, + MovedToWarehouseAtUtc = movedToWarehouseAtUtc, Message = "Record found and shipment marked successfully.", RejectionReason = null, ProcessedAtUtc = nowUtc @@ -176,6 +178,7 @@ public sealed class ShipmentVerificationService( UniqueNumber = result.UniqueNumber, ExistsInSystem = result.ExistsInSystem, ShipmentMarked = result.ShipmentMarked, + MovedToWarehouseAtUtc = result.MovedToWarehouseAtUtc, ProcessedAtUtc = result.ProcessedAtUtc }; } diff --git a/Services/SqliteScanHistoryStore.cs b/Services/SqliteScanHistoryStore.cs index d73afff..b32efef 100644 --- a/Services/SqliteScanHistoryStore.cs +++ b/Services/SqliteScanHistoryStore.cs @@ -25,6 +25,7 @@ public sealed class SqliteScanHistoryStore( UniqueNumber = record.UniqueNumber, ExistsInSystem = record.ExistsInSystem, ShipmentMarked = record.ShipmentMarked, + MovedToWarehouseAtUtc = record.MovedToWarehouseAtUtc, ProcessedAtUtc = record.ProcessedAtUtc }); @@ -170,6 +171,9 @@ public sealed class SqliteScanHistoryStore( UniqueNumber = x.UniqueNumber, ExistsInSystem = x.ExistsInSystem, ShipmentMarked = x.ShipmentMarked, + MovedToWarehouseAtUtc = x.MovedToWarehouseAtUtc.HasValue + ? DateTime.SpecifyKind(x.MovedToWarehouseAtUtc.Value, DateTimeKind.Utc) + : null, ProcessedAtUtc = DateTime.SpecifyKind(x.ProcessedAtUtc, DateTimeKind.Utc) }) .ToArray(); diff --git a/Views/Home/Index.cshtml b/Views/Home/Index.cshtml index 5008b88..7937770 100644 --- a/Views/Home/Index.cshtml +++ b/Views/Home/Index.cshtml @@ -88,6 +88,7 @@ MODEL NUMBER UNIQUE NUMBER STATUS + MOVED TO WAREHOUSE AT MARKED AT MARKED BY @@ -105,6 +106,7 @@ TRUE + @(item.MovedToWarehouseAtUtc.HasValue ? DateTime.SpecifyKind(item.MovedToWarehouseAtUtc.Value, DateTimeKind.Utc).ToLocalTime().ToString("dd-MM-yyyy HH:mm:ss") : "-") @DateTime.SpecifyKind(item.ProcessedAtUtc, DateTimeKind.Utc).ToLocalTime().ToString("dd MMM yyyy h:mm tt") System diff --git a/appsettings.json b/appsettings.json index 9d926b8..d4d6168 100644 --- a/appsettings.json +++ b/appsettings.json @@ -19,7 +19,8 @@ "CartonVerification": { "TableName": "carton_verification_log", "QrColumnName": "qr", - "MovedToWarehouseColumnName": "moved_to_warehouse" + "MovedToWarehouseColumnName": "moved_to_warehouse", + "MovedToWarehouseAtColumnName": "moved_to_warehouse_at" }, "ScanHistory": { "DatabasePath": "C:\\Users\\Public\\AVSCartonShipmentVerifier\\scan-history.db", diff --git a/wwwroot/js/verification-dashboard.js b/wwwroot/js/verification-dashboard.js index 3cfe203..205276a 100644 --- a/wwwroot/js/verification-dashboard.js +++ b/wwwroot/js/verification-dashboard.js @@ -356,6 +356,7 @@ ${sanitize(record.modelNumber)} ${sanitize(record.uniqueNumber)} TRUE + ${formatMovedToWarehouseAt(record.movedToWarehouseAtUtc)} ${formatDisplayTime(record.processedAtUtc)} System`; @@ -519,6 +520,7 @@ async function loadFullScanHistory(category) { const normalized = category === "rejected" ? "rejected" : "successful"; + const loadingColspan = normalized === "successful" ? 7 : 5; scanHistoryModalThead.innerHTML = ""; scanHistoryModalTbody.innerHTML = `Loading…`; @@ -528,7 +530,7 @@ if (!response.ok) { scanHistoryModalThead.innerHTML = ""; - scanHistoryModalTbody.innerHTML = `${sanitize(payload.message ?? "Unable to load history.")}`; + scanHistoryModalTbody.innerHTML = `${sanitize(payload.message ?? "Unable to load history.")}`; return; } @@ -540,7 +542,7 @@ } } catch { scanHistoryModalThead.innerHTML = ""; - scanHistoryModalTbody.innerHTML = `Unable to load history.`; + scanHistoryModalTbody.innerHTML = `Unable to load history.`; } } @@ -551,12 +553,13 @@ MODEL NUMBER UNIQUE NUMBER STATUS + MOVED TO WAREHOUSE AT MARKED AT MARKED BY `; if (!records.length) { - scanHistoryModalTbody.innerHTML = `No successful records yet.`; + scanHistoryModalTbody.innerHTML = `No successful records yet.`; return; } @@ -568,12 +571,39 @@ ${sanitize(record.modelNumber)} ${sanitize(record.uniqueNumber)} TRUE + ${formatMovedToWarehouseAt(record.movedToWarehouseAtUtc)} ${formatDisplayTime(record.processedAtUtc)} System`; scanHistoryModalTbody.appendChild(row); }); } + function formatMovedToWarehouseAt(utcValue) { + if (!utcValue) { + return "-"; + } + + const date = new Date(utcValue); + if (Number.isNaN(date.getTime())) { + return "-"; + } + + const parts = new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false + }).formatToParts(date).reduce((acc, part) => { + acc[part.type] = part.value; + return acc; + }, {}); + + return `${parts.day}-${parts.month}-${parts.year} ${parts.hour}:${parts.minute}:${parts.second}`; + } + function renderModalRejectedTable(records) { scanHistoryModalThead.innerHTML = `