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 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 IsSuccessful => ExistsInSystem && ShipmentMarked;
|
||||
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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,19 @@ namespace AVSCartonShipmentVerifier.Data;
|
|||
|
||||
public sealed class VerificationDbConnectionFactory(IConfiguration configuration) : IVerificationDbConnectionFactory
|
||||
{
|
||||
private readonly string _connectionString = configuration.GetConnectionString("VerificationDatabase")
|
||||
?? throw new InvalidOperationException("Connection string 'VerificationDatabase' is missing.");
|
||||
private readonly string _connectionString = ResolveVerificationDatabase(configuration);
|
||||
|
||||
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 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 connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
SELECT 1
|
||||
SELECT {QuoteIdentifier(_options.MovedToWarehouseColumnName)}
|
||||
FROM {QuoteIdentifier(_options.TableName)}
|
||||
WHERE {QuoteIdentifier(_options.QrColumnName)} = @uniqueNumber
|
||||
LIMIT 1;
|
||||
|
|
@ -30,7 +30,13 @@ public sealed class CartonVerificationRepository(
|
|||
command.Parameters.AddWithValue("@uniqueNumber", uniqueNumber);
|
||||
|
||||
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)
|
||||
|
|
@ -42,7 +48,8 @@ public sealed class CartonVerificationRepository(
|
|||
command.CommandText = $"""
|
||||
UPDATE {QuoteIdentifier(_options.TableName)}
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
namespace AVSCartonShipmentVerifier.Repositories;
|
||||
|
||||
public enum CartonVerificationStatus
|
||||
{
|
||||
NotFound,
|
||||
PendingTransfer,
|
||||
AlreadyTransferred
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@ namespace AVSCartonShipmentVerifier.Repositories;
|
|||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ namespace AVSCartonShipmentVerifier.Services;
|
|||
|
||||
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 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);
|
||||
if (!parseResult.IsValid)
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
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;
|
||||
bool exists;
|
||||
var parseResult = TryParseQr(raw);
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
_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
|
||||
{
|
||||
ModelNumber = parseResult.ModelNumber,
|
||||
UniqueNumber = parseResult.UniqueNumber,
|
||||
RawQrValue = rawQrValue,
|
||||
ExistsInSystem = false,
|
||||
ShipmentMarked = false,
|
||||
Message = "Shipment not completed. Unique number was not found.",
|
||||
ProcessedAtUtc = nowUtc
|
||||
};
|
||||
case CartonVerificationStatus.NotFound:
|
||||
await PersistRejectionAsync(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.CartonDoesNotExist, nowUtc, cancellationToken);
|
||||
return Reject(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.CartonDoesNotExist, existsInSystem: false, nowUtc);
|
||||
|
||||
await _historyStore.AddAsync(ToScannedRecord(missingResult), cancellationToken);
|
||||
return missingResult;
|
||||
case CartonVerificationStatus.AlreadyTransferred:
|
||||
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))
|
||||
{
|
||||
return (false, string.Empty, string.Empty, "QR value is required.");
|
||||
return (false, string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
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);
|
||||
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 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()
|
||||
{
|
||||
ModelNumber = result.ModelNumber,
|
||||
|
|
@ -122,4 +179,3 @@ public sealed class ShipmentVerificationService(
|
|||
ProcessedAtUtc = result.ProcessedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue