Add carton verification status checks
Adds the main verification flow for QR values, repository status lookup, rejection reasons, and richer verification response data.main
parent
267c4c8881
commit
b26af6ef4b
|
|
@ -3,5 +3,10 @@ namespace AVSCartonShipmentVerifier.DTOs;
|
||||||
public sealed class QrScanRequestDto
|
public sealed class QrScanRequestDto
|
||||||
{
|
{
|
||||||
public string QrValue { get; init; } = string.Empty;
|
public string QrValue { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scanner (keyboard wedge), Paste, or Manual (client-detected slow typing).
|
||||||
|
/// </summary>
|
||||||
|
public string EntryMethod { get; init; } = "Scanner";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace AVSCartonShipmentVerifier.DTOs;
|
||||||
|
|
||||||
|
public sealed class RejectedScanRecordDto
|
||||||
|
{
|
||||||
|
public string RawInputValue { get; init; } = string.Empty;
|
||||||
|
public string ModelNumber { get; init; } = string.Empty;
|
||||||
|
public string UniqueNumber { get; init; } = string.Empty;
|
||||||
|
public string RejectionReason { get; init; } = string.Empty;
|
||||||
|
public DateTime ProcessedAtUtc { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,12 @@ public sealed class VerificationResultDto
|
||||||
public bool ShipmentMarked { get; init; }
|
public bool ShipmentMarked { get; init; }
|
||||||
public bool IsSuccessful => ExistsInSystem && ShipmentMarked;
|
public bool IsSuccessful => ExistsInSystem && ShipmentMarked;
|
||||||
public string Message { get; init; } = string.Empty;
|
public string Message { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When the scan is rejected, matches <see cref="VerificationRejectionReasons"/> where applicable.
|
||||||
|
/// </summary>
|
||||||
|
public string? RejectionReason { get; init; }
|
||||||
|
|
||||||
public DateTime ProcessedAtUtc { get; init; }
|
public DateTime ProcessedAtUtc { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,19 @@ namespace AVSCartonShipmentVerifier.Data;
|
||||||
|
|
||||||
public sealed class VerificationDbConnectionFactory(IConfiguration configuration) : IVerificationDbConnectionFactory
|
public sealed class VerificationDbConnectionFactory(IConfiguration configuration) : IVerificationDbConnectionFactory
|
||||||
{
|
{
|
||||||
private readonly string _connectionString = configuration.GetConnectionString("VerificationDatabase")
|
private readonly string _connectionString = ResolveVerificationDatabase(configuration);
|
||||||
?? throw new InvalidOperationException("Connection string 'VerificationDatabase' is missing.");
|
|
||||||
|
|
||||||
public MySqlConnection CreateConnection() => new(_connectionString);
|
public MySqlConnection CreateConnection() => new(_connectionString);
|
||||||
|
|
||||||
|
private static string ResolveVerificationDatabase(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var cs = configuration.GetConnectionString("VerificationDatabase");
|
||||||
|
if (!string.IsNullOrWhiteSpace(cs))
|
||||||
|
return cs;
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Connection string 'VerificationDatabase' is not set. Add it via appsettings.Local.json (next to the executable), " +
|
||||||
|
"user secrets (Development), or the environment variable ConnectionStrings__VerificationDatabase.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,14 @@ public sealed class CartonVerificationRepository(
|
||||||
private readonly IVerificationDbConnectionFactory _connectionFactory = connectionFactory;
|
private readonly IVerificationDbConnectionFactory _connectionFactory = connectionFactory;
|
||||||
private readonly CartonVerificationOptions _options = options.Value;
|
private readonly CartonVerificationOptions _options = options.Value;
|
||||||
|
|
||||||
public async Task<bool> UniqueNumberExistsAsync(string uniqueNumber, CancellationToken cancellationToken = default)
|
public async Task<CartonVerificationStatus> GetCartonStatusByUniqueNumberAsync(string uniqueNumber, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await using var connection = _connectionFactory.CreateConnection();
|
await using var connection = _connectionFactory.CreateConnection();
|
||||||
await connection.OpenAsync(cancellationToken);
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
await using var command = connection.CreateCommand();
|
await using var command = connection.CreateCommand();
|
||||||
command.CommandText = $"""
|
command.CommandText = $"""
|
||||||
SELECT 1
|
SELECT {QuoteIdentifier(_options.MovedToWarehouseColumnName)}
|
||||||
FROM {QuoteIdentifier(_options.TableName)}
|
FROM {QuoteIdentifier(_options.TableName)}
|
||||||
WHERE {QuoteIdentifier(_options.QrColumnName)} = @uniqueNumber
|
WHERE {QuoteIdentifier(_options.QrColumnName)} = @uniqueNumber
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
@ -30,7 +30,13 @@ public sealed class CartonVerificationRepository(
|
||||||
command.Parameters.AddWithValue("@uniqueNumber", uniqueNumber);
|
command.Parameters.AddWithValue("@uniqueNumber", uniqueNumber);
|
||||||
|
|
||||||
var result = await command.ExecuteScalarAsync(cancellationToken);
|
var result = await command.ExecuteScalarAsync(cancellationToken);
|
||||||
return result is not null;
|
if (result is null || result is DBNull)
|
||||||
|
{
|
||||||
|
return CartonVerificationStatus.NotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
var moved = Convert.ToInt32(result);
|
||||||
|
return moved != 0 ? CartonVerificationStatus.AlreadyTransferred : CartonVerificationStatus.PendingTransfer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default)
|
public async Task<bool> MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default)
|
||||||
|
|
@ -42,7 +48,8 @@ public sealed class CartonVerificationRepository(
|
||||||
command.CommandText = $"""
|
command.CommandText = $"""
|
||||||
UPDATE {QuoteIdentifier(_options.TableName)}
|
UPDATE {QuoteIdentifier(_options.TableName)}
|
||||||
SET {QuoteIdentifier(_options.MovedToWarehouseColumnName)} = 1
|
SET {QuoteIdentifier(_options.MovedToWarehouseColumnName)} = 1
|
||||||
WHERE {QuoteIdentifier(_options.QrColumnName)} = @uniqueNumber;
|
WHERE {QuoteIdentifier(_options.QrColumnName)} = @uniqueNumber
|
||||||
|
AND {QuoteIdentifier(_options.MovedToWarehouseColumnName)} = 0;
|
||||||
""";
|
""";
|
||||||
command.Parameters.AddWithValue("@uniqueNumber", uniqueNumber);
|
command.Parameters.AddWithValue("@uniqueNumber", uniqueNumber);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace AVSCartonShipmentVerifier.Repositories;
|
||||||
|
|
||||||
|
public enum CartonVerificationStatus
|
||||||
|
{
|
||||||
|
NotFound,
|
||||||
|
PendingTransfer,
|
||||||
|
AlreadyTransferred
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,8 @@ namespace AVSCartonShipmentVerifier.Repositories;
|
||||||
|
|
||||||
public interface ICartonVerificationRepository
|
public interface ICartonVerificationRepository
|
||||||
{
|
{
|
||||||
Task<bool> UniqueNumberExistsAsync(string uniqueNumber, CancellationToken cancellationToken = default);
|
Task<CartonVerificationStatus> GetCartonStatusByUniqueNumberAsync(string uniqueNumber, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<bool> MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default);
|
Task<bool> MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ namespace AVSCartonShipmentVerifier.Services;
|
||||||
|
|
||||||
public interface IShipmentVerificationService
|
public interface IShipmentVerificationService
|
||||||
{
|
{
|
||||||
Task<VerificationResultDto> VerifyQrAsync(string rawQrValue, CancellationToken cancellationToken = default);
|
Task<VerificationResultDto> VerifyQrAsync(string rawQrValue, string? entryMethod, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,107 +12,164 @@ public sealed class ShipmentVerificationService(
|
||||||
private readonly IScanHistoryStore _historyStore = historyStore;
|
private readonly IScanHistoryStore _historyStore = historyStore;
|
||||||
private readonly ILogger<ShipmentVerificationService> _logger = logger;
|
private readonly ILogger<ShipmentVerificationService> _logger = logger;
|
||||||
|
|
||||||
public async Task<VerificationResultDto> VerifyQrAsync(string rawQrValue, CancellationToken cancellationToken = default)
|
public async Task<VerificationResultDto> VerifyQrAsync(string rawQrValue, string? entryMethod, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var parseResult = TryParseQr(rawQrValue);
|
var nowUtc = DateTime.UtcNow;
|
||||||
if (!parseResult.IsValid)
|
var raw = (rawQrValue ?? string.Empty).Trim();
|
||||||
|
|
||||||
|
if (IsManualEntry(entryMethod))
|
||||||
{
|
{
|
||||||
return BuildFailure(parseResult.ErrorMessage);
|
await PersistRejectionAsync(raw, string.Empty, string.Empty, VerificationRejectionReasons.ManualEntryNotAllowed, nowUtc, cancellationToken);
|
||||||
|
return Reject(raw, string.Empty, string.Empty, VerificationRejectionReasons.ManualEntryNotAllowed, existsInSystem: false, nowUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
var nowUtc = DateTime.UtcNow;
|
var parseResult = TryParseQr(raw);
|
||||||
bool exists;
|
if (!parseResult.IsValid)
|
||||||
|
{
|
||||||
|
await PersistRejectionAsync(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.InvalidFormat, nowUtc, cancellationToken);
|
||||||
|
return Reject(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.InvalidFormat, existsInSystem: false, nowUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
CartonVerificationStatus status;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
exists = await _repository.UniqueNumberExistsAsync(parseResult.UniqueNumber, cancellationToken);
|
status = await _repository.GetCartonStatusByUniqueNumberAsync(parseResult.UniqueNumber, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException exception) when (exception.Message.Contains("Connection string 'VerificationDatabase' is not set", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "Verification database is not configured.");
|
||||||
|
await PersistRejectionAsync(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, nowUtc, cancellationToken);
|
||||||
|
return Reject(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, existsInSystem: false, nowUtc);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
_logger.LogError(exception, "Verification lookup failed for unique number {UniqueNumber}.", parseResult.UniqueNumber);
|
_logger.LogError(exception, "Verification lookup failed for unique number {UniqueNumber}.", parseResult.UniqueNumber);
|
||||||
return BuildFailure("Unable to verify QR at the moment. Please try again.");
|
await PersistRejectionAsync(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, nowUtc, cancellationToken);
|
||||||
|
return Reject(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, existsInSystem: false, nowUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!exists)
|
switch (status)
|
||||||
{
|
{
|
||||||
var missingResult = new VerificationResultDto
|
case CartonVerificationStatus.NotFound:
|
||||||
{
|
await PersistRejectionAsync(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.CartonDoesNotExist, nowUtc, cancellationToken);
|
||||||
ModelNumber = parseResult.ModelNumber,
|
return Reject(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.CartonDoesNotExist, existsInSystem: false, nowUtc);
|
||||||
UniqueNumber = parseResult.UniqueNumber,
|
|
||||||
RawQrValue = rawQrValue,
|
|
||||||
ExistsInSystem = false,
|
|
||||||
ShipmentMarked = false,
|
|
||||||
Message = "Shipment not completed. Unique number was not found.",
|
|
||||||
ProcessedAtUtc = nowUtc
|
|
||||||
};
|
|
||||||
|
|
||||||
await _historyStore.AddAsync(ToScannedRecord(missingResult), cancellationToken);
|
case CartonVerificationStatus.AlreadyTransferred:
|
||||||
return missingResult;
|
await PersistRejectionAsync(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.AlreadyTransferredDuplicateScan, nowUtc, cancellationToken);
|
||||||
|
return Reject(
|
||||||
|
raw,
|
||||||
|
parseResult.ModelNumber,
|
||||||
|
parseResult.UniqueNumber,
|
||||||
|
VerificationRejectionReasons.AlreadyTransferredDuplicateScan,
|
||||||
|
existsInSystem: true,
|
||||||
|
nowUtc);
|
||||||
|
|
||||||
|
case CartonVerificationStatus.PendingTransfer:
|
||||||
|
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);
|
||||||
|
await PersistRejectionAsync(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, nowUtc, cancellationToken);
|
||||||
|
return Reject(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, existsInSystem: true, nowUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!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 success = new VerificationResultDto
|
||||||
|
{
|
||||||
|
ModelNumber = parseResult.ModelNumber,
|
||||||
|
UniqueNumber = parseResult.UniqueNumber,
|
||||||
|
RawQrValue = raw,
|
||||||
|
ExistsInSystem = true,
|
||||||
|
ShipmentMarked = true,
|
||||||
|
Message = "Record found and shipment marked successfully.",
|
||||||
|
RejectionReason = null,
|
||||||
|
ProcessedAtUtc = nowUtc
|
||||||
|
};
|
||||||
|
|
||||||
|
await _historyStore.AddSuccessfulScanAsync(ToScannedRecord(success), cancellationToken);
|
||||||
|
return success;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unexpected carton status: {status}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = marked,
|
|
||||||
Message = marked
|
|
||||||
? "Record found and shipment marked successfully."
|
|
||||||
: "Record found, but shipment could not be marked.",
|
|
||||||
ProcessedAtUtc = nowUtc
|
|
||||||
};
|
|
||||||
|
|
||||||
await _historyStore.AddAsync(ToScannedRecord(result), cancellationToken);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (bool IsValid, string ModelNumber, string UniqueNumber, string ErrorMessage) TryParseQr(string rawQrValue)
|
private static bool IsManualEntry(string? entryMethod) =>
|
||||||
|
string.Equals(entryMethod?.Trim(), "Manual", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private async Task PersistRejectionAsync(
|
||||||
|
string rawInput,
|
||||||
|
string modelNumber,
|
||||||
|
string uniqueNumber,
|
||||||
|
string reason,
|
||||||
|
DateTime processedAtUtc,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _historyStore.AddRejectedScanAsync(new RejectedScanRecordDto
|
||||||
|
{
|
||||||
|
RawInputValue = rawInput,
|
||||||
|
ModelNumber = modelNumber,
|
||||||
|
UniqueNumber = uniqueNumber,
|
||||||
|
RejectionReason = reason,
|
||||||
|
ProcessedAtUtc = processedAtUtc
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VerificationResultDto Reject(
|
||||||
|
string raw,
|
||||||
|
string modelNumber,
|
||||||
|
string uniqueNumber,
|
||||||
|
string reason,
|
||||||
|
bool existsInSystem,
|
||||||
|
DateTime processedAtUtc) => new()
|
||||||
|
{
|
||||||
|
RawQrValue = raw,
|
||||||
|
ModelNumber = modelNumber,
|
||||||
|
UniqueNumber = uniqueNumber,
|
||||||
|
ExistsInSystem = existsInSystem,
|
||||||
|
ShipmentMarked = false,
|
||||||
|
Message = reason,
|
||||||
|
RejectionReason = reason,
|
||||||
|
ProcessedAtUtc = processedAtUtc
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (bool IsValid, string ModelNumber, string UniqueNumber) TryParseQr(string rawQrValue)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(rawQrValue))
|
if (string.IsNullOrWhiteSpace(rawQrValue))
|
||||||
{
|
{
|
||||||
return (false, string.Empty, string.Empty, "QR value is required.");
|
return (false, string.Empty, string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rawQrValue.Contains(';'))
|
if (!rawQrValue.Contains(';'))
|
||||||
{
|
{
|
||||||
return (false, string.Empty, string.Empty, "Invalid QR format. Missing ';' separator. Use modelnumber;uniquenumber.");
|
return (false, string.Empty, string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
var parts = rawQrValue.Split(';', StringSplitOptions.TrimEntries);
|
var parts = rawQrValue.Split(';', StringSplitOptions.TrimEntries);
|
||||||
if (parts.Length != 2)
|
if (parts.Length != 2)
|
||||||
{
|
{
|
||||||
return (false, string.Empty, string.Empty, "Invalid QR format. Use modelnumber;uniquenumber.");
|
return (false, string.Empty, string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
var modelNumber = parts[0];
|
var modelNumber = parts[0];
|
||||||
var uniqueNumber = parts[1];
|
var uniqueNumber = parts[1];
|
||||||
if (string.IsNullOrWhiteSpace(uniqueNumber))
|
if (string.IsNullOrWhiteSpace(modelNumber) || string.IsNullOrWhiteSpace(uniqueNumber))
|
||||||
{
|
{
|
||||||
return (false, modelNumber, string.Empty, "Invalid QR format. Unique number is missing after ';'.");
|
return (false, modelNumber, uniqueNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (true, modelNumber, uniqueNumber, string.Empty);
|
return (true, modelNumber, uniqueNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static VerificationResultDto BuildFailure(string message) => new()
|
|
||||||
{
|
|
||||||
Message = message,
|
|
||||||
ExistsInSystem = false,
|
|
||||||
ShipmentMarked = false,
|
|
||||||
ProcessedAtUtc = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
private static ScannedRecordDto ToScannedRecord(VerificationResultDto result) => new()
|
private static ScannedRecordDto ToScannedRecord(VerificationResultDto result) => new()
|
||||||
{
|
{
|
||||||
ModelNumber = result.ModelNumber,
|
ModelNumber = result.ModelNumber,
|
||||||
|
|
@ -122,4 +179,3 @@ public sealed class ShipmentVerificationService(
|
||||||
ProcessedAtUtc = result.ProcessedAtUtc
|
ProcessedAtUtc = result.ProcessedAtUtc
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue