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 mappingmain
parent
bd4fd0d4b5
commit
d7cfdd191d
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
32
Program.cs
32
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())
|
||||
|
|
|
|||
|
|
@ -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}`";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
namespace AVSCartonShipmentVerifier.Repositories;
|
||||
|
||||
public sealed record ShipmentMarkResult(bool Marked, DateTime? MovedToWarehouseAtUtc);
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue