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
main
SYED MUSTUFA AHMED NAQVI 2026-06-19 11:47:30 +05:00
parent bd4fd0d4b5
commit d7cfdd191d
14 changed files with 120 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ public sealed class CartonVerificationRepository(
return moved != 0 ? CartonVerificationStatus.AlreadyTransferred : CartonVerificationStatus.PendingTransfer;
}
public async Task<bool> MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default)
public async Task<ShipmentMarkResult> 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<int> GetAvailableCartonCountAsync(CancellationToken cancellationToken = default)
@ -99,4 +126,3 @@ public sealed class CartonVerificationRepository(
return $"`{identifier}`";
}
}

View File

@ -4,10 +4,9 @@ public interface ICartonVerificationRepository
{
Task<CartonVerificationStatus> GetCartonStatusByUniqueNumberAsync(string uniqueNumber, CancellationToken cancellationToken = default);
Task<bool> MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default);
Task<ShipmentMarkResult> MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default);
Task<int> GetAvailableCartonCountAsync(CancellationToken cancellationToken = default);
Task<int> GetTotalMovedCartonCountAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,3 @@
namespace AVSCartonShipmentVerifier.Repositories;
public sealed record ShipmentMarkResult(bool Marked, DateTime? MovedToWarehouseAtUtc);

View File

@ -16,6 +16,7 @@ public sealed class ScanHistoryDbContext(DbContextOptions<ScanHistoryDbContext>
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);
});

View File

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

View File

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

View File

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

View File

@ -88,6 +88,7 @@
<th>MODEL NUMBER</th>
<th>UNIQUE NUMBER</th>
<th>STATUS</th>
<th>MOVED TO WAREHOUSE AT</th>
<th>MARKED AT</th>
<th>MARKED BY</th>
</tr>
@ -105,6 +106,7 @@
<td>
<span class="status-pill status-true">TRUE</span>
</td>
<td>@(item.MovedToWarehouseAtUtc.HasValue ? DateTime.SpecifyKind(item.MovedToWarehouseAtUtc.Value, DateTimeKind.Utc).ToLocalTime().ToString("dd-MM-yyyy HH:mm:ss") : "-")</td>
<td>@DateTime.SpecifyKind(item.ProcessedAtUtc, DateTimeKind.Utc).ToLocalTime().ToString("dd MMM yyyy h:mm tt")</td>
<td>System</td>
</tr>

View File

@ -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",

View File

@ -356,6 +356,7 @@
<td>${sanitize(record.modelNumber)}</td>
<td>${sanitize(record.uniqueNumber)}</td>
<td><span class="status-pill status-true">TRUE</span></td>
<td>${formatMovedToWarehouseAt(record.movedToWarehouseAtUtc)}</td>
<td>${formatDisplayTime(record.processedAtUtc)}</td>
<td>System</td>`;
@ -519,6 +520,7 @@
async function loadFullScanHistory(category) {
const normalized = category === "rejected" ? "rejected" : "successful";
const loadingColspan = normalized === "successful" ? 7 : 5;
scanHistoryModalThead.innerHTML = "";
scanHistoryModalTbody.innerHTML = `<tr><td colspan="6" class="scan-history-modal-empty">Loading…</td></tr>`;
@ -528,7 +530,7 @@
if (!response.ok) {
scanHistoryModalThead.innerHTML = "";
scanHistoryModalTbody.innerHTML = `<tr><td colspan="6" class="scan-history-modal-empty">${sanitize(payload.message ?? "Unable to load history.")}</td></tr>`;
scanHistoryModalTbody.innerHTML = `<tr><td colspan="${loadingColspan}" class="scan-history-modal-empty">${sanitize(payload.message ?? "Unable to load history.")}</td></tr>`;
return;
}
@ -540,7 +542,7 @@
}
} catch {
scanHistoryModalThead.innerHTML = "";
scanHistoryModalTbody.innerHTML = `<tr><td colspan="6" class="scan-history-modal-empty">Unable to load history.</td></tr>`;
scanHistoryModalTbody.innerHTML = `<tr><td colspan="${loadingColspan}" class="scan-history-modal-empty">Unable to load history.</td></tr>`;
}
}
@ -551,12 +553,13 @@
<th>MODEL NUMBER</th>
<th>UNIQUE NUMBER</th>
<th>STATUS</th>
<th>MOVED TO WAREHOUSE AT</th>
<th>MARKED AT</th>
<th>MARKED BY</th>
</tr>`;
if (!records.length) {
scanHistoryModalTbody.innerHTML = `<tr><td colspan="6" class="scan-history-modal-empty">No successful records yet.</td></tr>`;
scanHistoryModalTbody.innerHTML = `<tr><td colspan="7" class="scan-history-modal-empty">No successful records yet.</td></tr>`;
return;
}
@ -568,12 +571,39 @@
<td>${sanitize(record.modelNumber)}</td>
<td>${sanitize(record.uniqueNumber)}</td>
<td><span class="status-pill status-true">TRUE</span></td>
<td>${formatMovedToWarehouseAt(record.movedToWarehouseAtUtc)}</td>
<td>${formatDisplayTime(record.processedAtUtc)}</td>
<td>System</td>`;
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 = `
<tr>