Add carton verification status checks

Adds the main verification flow for QR values, repository status lookup, rejection reasons, and richer verification response data.
main
SYED MUSTUFA AHMED NAQVI 2026-05-15 11:40:13 +05:00
parent 267c4c8881
commit b26af6ef4b
9 changed files with 175 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
namespace AVSCartonShipmentVerifier.Repositories;
public enum CartonVerificationStatus
{
NotFound,
PendingTransfer,
AlreadyTransferred
}

View File

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

View File

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

View File

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