diff --git a/DTOs/QrScanRequestDto.cs b/DTOs/QrScanRequestDto.cs index 6ac81ec..260f4ed 100644 --- a/DTOs/QrScanRequestDto.cs +++ b/DTOs/QrScanRequestDto.cs @@ -3,5 +3,10 @@ namespace AVSCartonShipmentVerifier.DTOs; public sealed class QrScanRequestDto { public string QrValue { get; init; } = string.Empty; + + /// + /// Scanner (keyboard wedge), Paste, or Manual (client-detected slow typing). + /// + public string EntryMethod { get; init; } = "Scanner"; } diff --git a/DTOs/RejectedScanRecordDto.cs b/DTOs/RejectedScanRecordDto.cs new file mode 100644 index 0000000..9ca6e3e --- /dev/null +++ b/DTOs/RejectedScanRecordDto.cs @@ -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; } +} diff --git a/DTOs/VerificationResultDto.cs b/DTOs/VerificationResultDto.cs index 46c0f35..1916080 100644 --- a/DTOs/VerificationResultDto.cs +++ b/DTOs/VerificationResultDto.cs @@ -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; + + /// + /// When the scan is rejected, matches where applicable. + /// + public string? RejectionReason { get; init; } + public DateTime ProcessedAtUtc { get; init; } } diff --git a/Data/VerificationDbConnectionFactory.cs b/Data/VerificationDbConnectionFactory.cs index 257e03b..0c1ab20 100644 --- a/Data/VerificationDbConnectionFactory.cs +++ b/Data/VerificationDbConnectionFactory.cs @@ -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."); + } } diff --git a/Repositories/CartonVerificationRepository.cs b/Repositories/CartonVerificationRepository.cs index 740b9a8..2d07d48 100644 --- a/Repositories/CartonVerificationRepository.cs +++ b/Repositories/CartonVerificationRepository.cs @@ -15,14 +15,14 @@ public sealed class CartonVerificationRepository( private readonly IVerificationDbConnectionFactory _connectionFactory = connectionFactory; private readonly CartonVerificationOptions _options = options.Value; - public async Task UniqueNumberExistsAsync(string uniqueNumber, CancellationToken cancellationToken = default) + public async Task 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 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); diff --git a/Repositories/CartonVerificationStatus.cs b/Repositories/CartonVerificationStatus.cs new file mode 100644 index 0000000..0c2d58a --- /dev/null +++ b/Repositories/CartonVerificationStatus.cs @@ -0,0 +1,8 @@ +namespace AVSCartonShipmentVerifier.Repositories; + +public enum CartonVerificationStatus +{ + NotFound, + PendingTransfer, + AlreadyTransferred +} diff --git a/Repositories/ICartonVerificationRepository.cs b/Repositories/ICartonVerificationRepository.cs index d5738e7..8c7c99a 100644 --- a/Repositories/ICartonVerificationRepository.cs +++ b/Repositories/ICartonVerificationRepository.cs @@ -2,7 +2,8 @@ namespace AVSCartonShipmentVerifier.Repositories; public interface ICartonVerificationRepository { - Task UniqueNumberExistsAsync(string uniqueNumber, CancellationToken cancellationToken = default); + Task GetCartonStatusByUniqueNumberAsync(string uniqueNumber, CancellationToken cancellationToken = default); + Task MarkShipmentVerifiedAsync(string uniqueNumber, CancellationToken cancellationToken = default); } diff --git a/Services/IShipmentVerificationService.cs b/Services/IShipmentVerificationService.cs index 12d32c1..69fd6be 100644 --- a/Services/IShipmentVerificationService.cs +++ b/Services/IShipmentVerificationService.cs @@ -4,6 +4,6 @@ namespace AVSCartonShipmentVerifier.Services; public interface IShipmentVerificationService { - Task VerifyQrAsync(string rawQrValue, CancellationToken cancellationToken = default); + Task VerifyQrAsync(string rawQrValue, string? entryMethod, CancellationToken cancellationToken = default); } diff --git a/Services/ShipmentVerificationService.cs b/Services/ShipmentVerificationService.cs index b2ec63c..6f1b47b 100644 --- a/Services/ShipmentVerificationService.cs +++ b/Services/ShipmentVerificationService.cs @@ -12,107 +12,164 @@ public sealed class ShipmentVerificationService( private readonly IScanHistoryStore _historyStore = historyStore; private readonly ILogger _logger = logger; - public async Task VerifyQrAsync(string rawQrValue, CancellationToken cancellationToken = default) + public async Task 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 }; } -