feature: Corrections and Supersession

This commit is contained in:
voltsrage
2026-06-26 16:33:31 +08:00
parent 706318e5d2
commit f232761fd7
35 changed files with 4907 additions and 75 deletions
+61 -8
View File
@@ -30,14 +30,47 @@ public class BatchService : IBatchService
_logger = logger;
}
public async Task<DigitizationBatch> CreateAsync(
public async Task<CreateBatchResult> CreateAsync(
Stream fileStream, string contentType, BatchType batchType,
BatchTrack track, Guid? patientId, Guid actorUserId)
BatchTrack track, Guid? patientId, Guid? supersedesBatchId, Guid actorUserId)
{
// Supersession validation: target batch must exist and be in Promoted status
DigitizationBatch? supersededBatch = null;
if (supersedesBatchId.HasValue)
{
supersededBatch = await _db.DigitizationBatches
.FirstOrDefaultAsync(b => b.Id == supersedesBatchId.Value);
if (supersededBatch is null)
throw new NotFoundException(
$"Batch {supersedesBatchId.Value} not found.",
"SUPERSEDED_BATCH_NOT_FOUND");
if (supersededBatch.Status != BatchStatus.Promoted)
throw new ValidationException(
"Only promoted batches can be superseded. " +
$"Batch {supersedesBatchId.Value} is in '{supersededBatch.Status.ToDbString()}' status.",
"SUPERSEDED_BATCH_NOT_PROMOTED");
// Prevent supersession chains: the target batch must not itself be a correction
// that has already been superseded by another promoted correction
var existingCorrection = await _db.DigitizationBatches
.AnyAsync(b => b.SupersedesBatchId == supersedesBatchId.Value
&& b.Status == BatchStatus.Promoted);
if (existingCorrection)
throw new ConflictException(
$"Batch {supersedesBatchId.Value} has already been superseded by a promoted correction. " +
"Create a new correction against the latest promoted batch instead.",
"BATCH_ALREADY_SUPERSEDED");
// Inherit patient context from the superseded batch
patientId ??= supersededBatch.PatientId;
}
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.
// Duplicate detection: same SHA-256 for same patient within 24 hours
if (patientId.HasValue)
{
var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
@@ -63,6 +96,7 @@ public class BatchService : IBatchService
DocumentRef = objectKey,
DocumentSha256 = sha256,
EnableRetroactiveAlerts = false,
SupersedesBatchId = supersedesBatchId,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
@@ -78,13 +112,21 @@ public class BatchService : IBatchService
UploadedAt = DateTimeOffset.UtcNow
};
var eventType = supersedesBatchId.HasValue
? DigitizationEventType.CorrectionUploaded
: DigitizationEventType.Uploaded;
var eventMetadata = supersedesBatchId.HasValue
? JsonSerializer.Serialize(new { supersedesBatchId = supersedesBatchId.Value })
: null;
var evt = new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.Uploaded,
EventType = eventType,
ActorUserId = actorUserId,
OccurredAt = DateTimeOffset.UtcNow
OccurredAt = DateTimeOffset.UtcNow,
MetadataJson = eventMetadata
};
_db.DigitizationBatches.Add(batch);
@@ -92,8 +134,19 @@ public class BatchService : IBatchService
_db.DigitizationEvents.Add(evt);
await _db.SaveChangesAsync();
_logger.LogInformation("Batch {BatchId} created with document {ObjectKey}", batchId, objectKey);
return batch;
_logger.LogInformation(
"Batch {BatchId} created (correction={IsCorrection}, supersedes={SupersedesBatchId})",
batchId, supersedesBatchId.HasValue, supersedesBatchId);
SupersessionInfo? supersession = null;
if (supersededBatch is not null)
{
var obsCount = await _db.Observations
.CountAsync(o => o.SourceBatchId == supersededBatch.Id);
supersession = BatchDetailResponse.ToSupersessionInfo(supersededBatch, obsCount);
}
return new CreateBatchResult(batch, supersession);
}
public async Task<DigitizationBatch> GetByIdAsync(Guid id)