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

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

View File

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

View File

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

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

View File

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

View File

@ -12,43 +12,59 @@ 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; bool marked;
try try
{ {
@ -57,62 +73,103 @@ public sealed class ShipmentVerificationService(
catch (Exception exception) catch (Exception exception)
{ {
_logger.LogError(exception, "Failed to mark shipment verified for unique number {UniqueNumber}.", parseResult.UniqueNumber); _logger.LogError(exception, "Failed to mark shipment verified for unique number {UniqueNumber}.", parseResult.UniqueNumber);
marked = false; await PersistRejectionAsync(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, nowUtc, cancellationToken);
return Reject(raw, parseResult.ModelNumber, parseResult.UniqueNumber, VerificationRejectionReasons.DatabaseOrSystemError, existsInSystem: true, nowUtc);
} }
var result = new VerificationResultDto 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, ModelNumber = parseResult.ModelNumber,
UniqueNumber = parseResult.UniqueNumber, UniqueNumber = parseResult.UniqueNumber,
RawQrValue = rawQrValue, RawQrValue = raw,
ExistsInSystem = true, ExistsInSystem = true,
ShipmentMarked = marked, ShipmentMarked = true,
Message = marked Message = "Record found and shipment marked successfully.",
? "Record found and shipment marked successfully." RejectionReason = null,
: "Record found, but shipment could not be marked.",
ProcessedAtUtc = nowUtc ProcessedAtUtc = nowUtc
}; };
await _historyStore.AddAsync(ToScannedRecord(result), cancellationToken); await _historyStore.AddSuccessfulScanAsync(ToScannedRecord(success), cancellationToken);
return result; return success;
default:
throw new InvalidOperationException($"Unexpected carton status: {status}.");
}
} }
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
}; };
} }