using System.Text.Json; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; public class BatchService : IBatchService { private static readonly Dictionary> _allowedTransitions = new() { [BatchStatus.Uploaded] = new() { BatchStatus.InEntry }, [BatchStatus.InEntry] = new() { BatchStatus.PendingVerification }, [BatchStatus.PendingVerification] = new() { BatchStatus.Verified, BatchStatus.Rejected, BatchStatus.AwaitingClinicalApproval }, [BatchStatus.Rejected] = new() { BatchStatus.InEntry }, [BatchStatus.Verified] = new() { BatchStatus.Approved }, [BatchStatus.AwaitingClinicalApproval] = new() { BatchStatus.Approved, BatchStatus.Rejected }, [BatchStatus.Approved] = new() { BatchStatus.Promoted }, [BatchStatus.Promoted] = new(), }; private readonly AppDbContext _db; private readonly IDocumentStorageService _storage; private readonly IConnectionMultiplexer _redis; private readonly ILogger _logger; public BatchService(AppDbContext db, IDocumentStorageService storage, IConnectionMultiplexer redis, ILogger logger) { _db = db; _storage = storage; _redis = redis; _logger = logger; } public async Task CreateAsync( Stream fileStream, string contentType, BatchType batchType, BatchTrack track, Guid? patientId, Guid actorUserId) { var (objectKey, sha256, fileSize) = await _storage.UploadAsync(fileStream, contentType, Guid.NewGuid()); // Duplicate detection: same SHA-256 for same patient within 24 hours. // Cross-patient duplicates (same form scanned for two patients) are allowed. if (patientId.HasValue) { var cutoff = DateTimeOffset.UtcNow.AddHours(-24); var duplicate = await _db.DigitizationBatches.AnyAsync(b => b.DocumentSha256 == sha256 && b.PatientId == patientId.Value && b.CreatedAt >= cutoff); if (duplicate) throw new ConflictException( "A document with the same content was uploaded for this patient within the last 24 hours.", "DUPLICATE_DOCUMENT"); } var batchId = Guid.NewGuid(); var batch = new DigitizationBatch { Id = batchId, Status = BatchStatus.Uploaded, BatchType = batchType, Track = track, PatientId = patientId, DocumentRef = objectKey, DocumentSha256 = sha256, EnableRetroactiveAlerts = false, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow }; var document = new ScannedDocument { Id = Guid.NewGuid(), BatchId = batchId, ObjectKey = objectKey, Sha256 = sha256, ContentType = contentType, FileSizeBytes = fileSize, UploadedAt = DateTimeOffset.UtcNow }; var evt = new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batchId, EventType = DigitizationEventType.Uploaded, ActorUserId = actorUserId, OccurredAt = DateTimeOffset.UtcNow }; _db.DigitizationBatches.Add(batch); _db.ScannedDocuments.Add(document); _db.DigitizationEvents.Add(evt); await _db.SaveChangesAsync(); _logger.LogInformation("Batch {BatchId} created with document {ObjectKey}", batchId, objectKey); return batch; } public async Task GetByIdAsync(Guid id) { var batch = await _db.DigitizationBatches .Include(b => b.Document) .Include(b => b.DraftPatient) .Include(b => b.DraftEncounter) .Include(b => b.DraftObservations) .FirstOrDefaultAsync(b => b.Id == id); if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); return batch; } public async Task> ListAsync( BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track, int page, int pageSize) { var query = _db.DigitizationBatches.AsQueryable(); if (status.HasValue) query = query.Where(b => b.Status == status.Value); if (batchType.HasValue) query = query.Where(b => b.BatchType == batchType.Value); if (assignedTo.HasValue) query = query.Where(b => b.EnteredByUserId == assignedTo.Value); if (track.HasValue) query = query.Where(b => b.Track == track.Value); var total = await query.CountAsync(); var items = await query .OrderByDescending(b => b.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return new PagedResult(items, page, pageSize, total); } public async Task AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId) { var batch = await _db.DigitizationBatches.FindAsync(batchId); if (batch is null) throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND"); if (batch.Status != BatchStatus.Uploaded) throw new ConflictException( "Only batches in 'uploaded' status can be assigned.", "ILLEGAL_STATUS_TRANSITION"); // Redis lock to prevent double-assignment var cache = _redis.GetDatabase(); var lockKey = $"batch:assign:{batchId}"; var acquired = await cache.StringSetAsync(lockKey, entryClerkUserId.ToString(), TimeSpan.FromHours(1), When.NotExists); if (!acquired) throw new ConflictException( "This batch is already assigned to another clerk.", "BATCH_ALREADY_ASSIGNED"); batch.EnteredByUserId = entryClerkUserId; batch.UpdatedAt = DateTimeOffset.UtcNow; _db.DigitizationEvents.Add(new DigitizationEvent { Id = Guid.NewGuid(), BatchId = batchId, EventType = DigitizationEventType.EntryStarted, ActorUserId = actorUserId, OccurredAt = DateTimeOffset.UtcNow, MetadataJson = JsonSerializer.Serialize(new { assignedTo = entryClerkUserId }) }); await _db.SaveChangesAsync(); return batch; } public BatchStatus[] GetAllowedTransitions(BatchStatus current) => _allowedTransitions.TryGetValue(current, out var targets) ? targets.ToArray() : Array.Empty(); }