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