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)
@@ -0,0 +1,122 @@
using Microsoft.EntityFrameworkCore;
public class DigitizationHistoryService : IDigitizationHistoryService
{
private readonly AppDbContext _db;
public DigitizationHistoryService(AppDbContext db)
{
_db = db;
}
public async Task<PatientDigitizationHistoryResponse> GetPatientHistoryAsync(Guid patientId)
{
// Verify the patient exists (has at least one batch)
var hasBatches = await _db.DigitizationBatches
.AnyAsync(b => b.PatientId == patientId);
if (!hasBatches)
throw new NotFoundException(
$"No digitization history found for patient {patientId}.",
"PATIENT_HISTORY_NOT_FOUND");
// Load all batches for this patient with their relationships
var batches = await _db.DigitizationBatches
.Include(b => b.DraftObservations)
.Include(b => b.Events)
.ThenInclude(e => e.Actor)
.Include(b => b.EnteredByUser)
.Include(b => b.VerifiedByUser)
.Include(b => b.ApprovedByUser)
.Where(b => b.PatientId == patientId)
.OrderByDescending(b => b.CreatedAt)
.ToListAsync();
// For each promoted batch, count live vs superseded observations
var promotedBatchIds = batches
.Where(b => b.Status == BatchStatus.Promoted)
.Select(b => b.Id)
.ToList();
var liveObservationCounts = await _db.LiveObservations
.Where(o => promotedBatchIds.Contains(o.SourceBatchId))
.GroupBy(o => new { o.SourceBatchId, o.IsSuperseded })
.Select(g => new
{
g.Key.SourceBatchId,
g.Key.IsSuperseded,
Count = g.Count()
})
.ToListAsync();
// Build a lookup: batchId -> (activeCount, supersededCount)
var obsCountLookup = promotedBatchIds.ToDictionary(
id => id,
id =>
{
var active = liveObservationCounts
.FirstOrDefault(x => x.SourceBatchId == id && !x.IsSuperseded)?.Count ?? 0;
var superseded = liveObservationCounts
.FirstOrDefault(x => x.SourceBatchId == id && x.IsSuperseded)?.Count ?? 0;
return (Active: active, Superseded: superseded);
});
// Build a lookup for "has been superseded" — batches that appear as
// SupersedesBatchId on another batch that reached Promoted
var supersededByLookup = await _db.DigitizationBatches
.Where(b => b.SupersedesBatchId.HasValue
&& b.Status == BatchStatus.Promoted
&& promotedBatchIds.Contains(b.SupersedesBatchId.Value))
.ToDictionaryAsync(
b => b.SupersedesBatchId!.Value,
b => b.Id);
var entries = batches.Select(b =>
{
var counts = obsCountLookup.GetValueOrDefault(b.Id, (Active: 0, Superseded: 0));
var hasBeenSuperseded = supersededByLookup.ContainsKey(b.Id);
supersededByLookup.TryGetValue(b.Id, out var supersededByBatchId);
return new DigitizationHistoryEntry(
BatchId: b.Id,
Status: b.Status.ToDbString(),
BatchType: b.BatchType.ToDbString(),
Track: b.Track.ToDbString(),
SupersedesBatchId: b.SupersedesBatchId,
IsCorrection: b.SupersedesBatchId.HasValue,
HasBeenSuperseded: hasBeenSuperseded,
SupersededByBatchId: hasBeenSuperseded ? supersededByBatchId : null,
DraftObservationCount: b.DraftObservations.Count,
LiveObservationCount: counts.Active,
SupersededObservationCount: counts.Superseded,
CreatedAt: b.CreatedAt,
PromotedAt: b.PromotedAt,
PromotionEncounterId: b.PromotionEncounterId,
EnteredByUserId: b.EnteredByUserId,
EnteredByUserName: b.EnteredByUser?.FullName,
VerifiedByUserId: b.VerifiedByUserId,
VerifiedByUserName: b.VerifiedByUser?.FullName,
ApprovedByUserId: b.ApprovedByUserId,
ApprovedByUserName: b.ApprovedByUser?.FullName,
AuditTrail: b.Events
.OrderBy(e => e.OccurredAt)
.Select(e => new DigitizationEventSummary(
e.EventType.ToDbString(),
e.OccurredAt,
e.ActorUserId,
e.Actor?.FullName ?? "Unknown",
e.MetadataJson))
.ToList()
);
}).ToList();
return new PatientDigitizationHistoryResponse(
PatientId: patientId,
TotalBatches: entries.Count,
PromotedBatches: entries.Count(e => e.Status == "PROMOTED"),
SupersededBatches: entries.Count(e => e.HasBeenSuperseded),
PendingBatches: entries.Count(e => e.Status != "PROMOTED" && e.Status != "REJECTED"),
Entries: entries
);
}
}
+8 -5
View File
@@ -453,12 +453,15 @@ public class DraftService : IDraftService
private static void ValidateLabResults(
DigitizationBatch batch, List<string> errors)
{
// Required: Linked patient, encounter, >= 1 lab observation code, recordedAt
if (batch.DraftPatient is null)
errors.Add("Linked patient is required for lab results");
// Corrections inherit patient and encounter from the superseded promoted batch
if (!batch.SupersedesBatchId.HasValue)
{
if (batch.DraftPatient is null)
errors.Add("Linked patient is required for lab results");
if (batch.DraftEncounter is null)
errors.Add("Encounter context is required for lab results");
if (batch.DraftEncounter is null)
errors.Add("Encounter context is required for lab results");
}
if (batch.DraftObservations.Count == 0)
errors.Add("At least one lab observation is required");
@@ -1,8 +1,22 @@
public interface IBatchService
{
Task<DigitizationBatch> CreateAsync(Stream fileStream, string contentType, BatchType batchType, BatchTrack track, Guid? patientId, Guid actorUserId);
Task<CreateBatchResult> CreateAsync(
Stream fileStream,
string contentType,
BatchType batchType,
BatchTrack track,
Guid? patientId,
Guid? supersedesBatchId,
Guid actorUserId);
Task<DigitizationBatch> GetByIdAsync(Guid id);
Task<PagedResult<DigitizationBatch>> ListAsync(BatchStatus? status, BatchType? batchType, Guid? assignedTo, BatchTrack? track, int page, int pageSize);
Task<PagedResult<DigitizationBatch>> ListAsync(
BatchStatus? status, BatchType? batchType,
Guid? assignedTo, BatchTrack? track,
int page, int pageSize);
Task<DigitizationBatch> AssignAsync(Guid batchId, Guid entryClerkUserId, Guid actorUserId);
BatchStatus[] GetAllowedTransitions(BatchStatus current);
}
@@ -0,0 +1,4 @@
public interface IDigitizationHistoryService
{
Task<PatientDigitizationHistoryResponse> GetPatientHistoryAsync(Guid patientId);
}
@@ -20,4 +20,6 @@ public interface IPromotionService
/// Returns the promotion result for an already-promoted batch.
/// </summary>
Task<PromotionResultResponse> GetPromotionResultAsync(Guid batchId);
Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId);
}
+363 -19
View File
@@ -67,12 +67,21 @@ public class PromotionService : IPromotionService
"Separation of duties: the verifier cannot also approve the same batch.",
"SEPARATION_OF_DUTIES_VIOLATION");
// --- Validate draft data completeness ---
if (batch.DraftPatient is null)
throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT");
// --- Validate draft data completeness (corrections reuse the linked live patient) ---
if (!batch.SupersedesBatchId.HasValue)
{
if (batch.DraftPatient is null)
throw new ValidationException("Batch has no draft patient data.", "MISSING_DRAFT_PATIENT");
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT");
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
throw new ValidationException("Draft patient must have a full name.", "INVALID_DRAFT_PATIENT");
}
else if (!batch.PatientId.HasValue)
{
throw new ValidationException(
"Correction batch has no linked patient.",
"MISSING_PATIENT");
}
// --- Begin atomic transaction ---
await using var transaction = await _db.Database.BeginTransactionAsync();
@@ -82,41 +91,79 @@ public class PromotionService : IPromotionService
var now = DateTimeOffset.UtcNow;
// === Step 1: Create or update Patient ===
var patient = await CreateOrUpdatePatientAsync(batch.DraftPatient, now);
Patient patient;
if (batch.SupersedesBatchId.HasValue)
{
patient = await _db.Patients.FirstOrDefaultAsync(p => p.Id == batch.PatientId!.Value)
?? throw new NotFoundException(
$"Patient {batch.PatientId.Value} not found.",
"PATIENT_NOT_FOUND");
}
else
{
patient = await CreateOrUpdatePatientAsync(batch.DraftPatient!, now);
}
// === Step 2: Create or match Encounter ===
var encounter = await CreateOrMatchEncounterAsync(batch, patient.Id, now);
// === Step 2: Create or match Encounter (reuse original encounter for corrections) ===
var encounter = await ResolveClinicalEncounterAsync(batch, patient.Id, now);
await EnsureLiveEncounterAsync(encounter, now);
// === Step 3: Insert each DraftObservation as live Observation ===
var (observationIds, outboxCount) = await PromoteObservationsAsync(
batch, patient.Id, encounter.Id, enableRetroactiveAlerts, now);
// === Step 3b: Mirror to live_observations for supersession / digitization history ===
PromoteLiveObservationsAsync(batch, patient.Id, encounter.Id, now);
SupersessionResult? supersessionResult = null;
if (batch.SupersedesBatchId.HasValue)
{
supersessionResult = await SupersedeOriginalBatchAsync(
batch.SupersedesBatchId.Value, batchId, approverUserId, now);
}
// === Step 4: Update batch status to Promoted ===
batch.Status = BatchStatus.Promoted;
batch.ApprovedByUserId = approverUserId;
batch.PatientId = patient.Id;
batch.PromotedAt = now;
batch.PromotionEncounterId = encounter.Id;
batch.EnableRetroactiveAlerts = enableRetroactiveAlerts;
batch.UpdatedAt = now;
// === Step 5: Write DigitizationEvent ===
var promotionMetadata = new Dictionary<string, object>
{
["patientId"] = patient.Id,
["mrn"] = patient.Mrn,
["encounterId"] = encounter.Id,
["observationCount"] = observationIds.Length,
["outboxEventsWritten"] = outboxCount,
["enableRetroactiveAlerts"] = enableRetroactiveAlerts,
["track"] = batch.Track.ToDbString(),
["isCorrection"] = batch.SupersedesBatchId.HasValue
};
if (supersessionResult is not null)
{
promotionMetadata["supersession"] = new
{
originalBatchId = supersessionResult.OriginalBatchId,
observationsSuperseded = supersessionResult.ObservationsSuperseded,
observationsReplaced = supersessionResult.ObservationsReplaced
};
}
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batchId,
EventType = DigitizationEventType.Promoted,
EventType = batch.SupersedesBatchId.HasValue
? DigitizationEventType.CorrectionPromoted
: DigitizationEventType.Promoted,
ActorUserId = approverUserId,
OccurredAt = now,
MetadataJson = JsonSerializer.Serialize(new
{
patientId = patient.Id,
mrn = patient.Mrn,
encounterId = encounter.Id,
observationCount = observationIds.Length,
outboxEventsWritten = outboxCount,
enableRetroactiveAlerts,
track = batch.Track.ToDbString()
})
MetadataJson = JsonSerializer.Serialize(promotionMetadata)
});
// === Step 6: Store idempotency record (within same transaction) ===
@@ -258,6 +305,80 @@ public class PromotionService : IPromotionService
return patient;
}
private async Task<Encounter> ResolveClinicalEncounterAsync(
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
{
if (batch.SupersedesBatchId.HasValue)
{
var originalBatch = await _db.DigitizationBatches
.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == batch.SupersedesBatchId.Value);
if (originalBatch?.PromotionEncounterId is not Guid encounterId)
throw new ValidationException(
"Original batch has no promotion encounter for correction reuse.",
"MISSING_ORIGINAL_ENCOUNTER");
var encounter = await _db.Encounters.FirstOrDefaultAsync(e => e.Id == encounterId);
if (encounter is null)
throw new NotFoundException(
"Original promotion encounter not found.",
"PROMOTION_ENCOUNTER_NOT_FOUND");
_logger.LogInformation(
"Correction batch {CorrectionBatchId} reusing clinical encounter {EncounterId} " +
"from original batch {OriginalBatchId}",
batch.Id, encounter.Id, originalBatch.Id);
return encounter;
}
return await CreateOrMatchEncounterAsync(batch, patientId, now);
}
private async Task EnsureLiveEncounterAsync(Encounter encounter, DateTimeOffset now)
{
if (await _db.LiveEncounters.AnyAsync(e => e.Id == encounter.Id))
return;
_db.LiveEncounters.Add(new LiveEncounter
{
Id = encounter.Id,
PatientId = encounter.PatientId,
AdmissionDate = encounter.AdmissionDate ?? now,
Department = encounter.Department,
RoomBed = encounter.RoomBed,
AdmissionReason = encounter.AdmissionReason,
DischargeDiagnosis = encounter.DischargeDiagnosis,
Status = encounter.Status,
CreatedAt = now
});
}
private void PromoteLiveObservationsAsync(
DigitizationBatch batch, Guid patientId, Guid encounterId, DateTimeOffset now)
{
foreach (var draft in batch.DraftObservations)
{
_db.LiveObservations.Add(new LiveObservation
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
PatientId = patientId,
SourceBatchId = batch.Id,
ObservationCode = draft.ObservationCode,
Value = draft.Value,
Unit = draft.Unit,
RecordedAt = draft.RecordedAt,
Note = draft.Note,
CreatedAt = now,
IsSuperseded = false,
SupersededByBatchId = null,
SupersededAt = null
});
}
}
private async Task<Encounter> CreateOrMatchEncounterAsync(
DigitizationBatch batch, Guid patientId, DateTimeOffset now)
{
@@ -389,4 +510,227 @@ public class PromotionService : IPromotionService
return (observationIds.ToArray(), outboxCount);
}
public async Task<PromotionResult> PromoteAsync(Guid batchId, Guid actorUserId)
{
await using var transaction = await _db.Database.BeginTransactionAsync();
try
{
// Load the batch with all draft data
var batch = await _db.DigitizationBatches
.Include(b => b.DraftPatient)
.Include(b => b.DraftEncounter)
.Include(b => b.DraftObservations)
.FirstOrDefaultAsync(b => b.Id == batchId);
if (batch is null)
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
if (batch.Status != BatchStatus.Approved)
throw new ConflictException(
$"Only approved batches can be promoted. Batch is in '{batch.Status.ToDbString()}' status.",
"ILLEGAL_STATUS_TRANSITION");
// Resolve or create the live encounter
var encounterId = await ResolveEncounterAsync(batch);
// Promote draft observations to live observations
var now = DateTimeOffset.UtcNow;
var liveObservations = batch.DraftObservations.Select(draft => new LiveObservation
{
Id = Guid.NewGuid(),
EncounterId = encounterId,
PatientId = batch.PatientId,
SourceBatchId = batch.Id,
ObservationCode = draft.ObservationCode,
Value = draft.Value,
Unit = draft.Unit,
RecordedAt = draft.RecordedAt,
Note = draft.Note,
CreatedAt = now,
IsSuperseded = false,
SupersededByBatchId = null,
SupersededAt = null
}).ToList();
_db.LiveObservations.AddRange(liveObservations);
// Handle supersession if this is a correction batch
SupersessionResult? supersessionResult = null;
if (batch.SupersedesBatchId.HasValue)
{
supersessionResult = await SupersedeOriginalBatchAsync(
batch.SupersedesBatchId.Value,
batch.Id,
actorUserId,
now);
}
// Update batch status to Promoted
batch.Status = BatchStatus.Promoted;
batch.PromotedAt = now;
batch.PromotionEncounterId = encounterId;
batch.UpdatedAt = now;
// Record promotion event on the correction batch
var promotionMetadata = new Dictionary<string, object>
{
["encounterId"] = encounterId,
["observationsPromoted"] = liveObservations.Count,
["isCorrection"] = batch.SupersedesBatchId.HasValue
};
if (supersessionResult is not null)
{
promotionMetadata["supersession"] = new
{
originalBatchId = supersessionResult.OriginalBatchId,
observationsSuperseded = supersessionResult.ObservationsSuperseded,
observationsReplaced = supersessionResult.ObservationsReplaced
};
}
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = batch.Id,
EventType = batch.SupersedesBatchId.HasValue
? DigitizationEventType.CorrectionPromoted
: DigitizationEventType.Promoted,
ActorUserId = actorUserId,
OccurredAt = now,
MetadataJson = JsonSerializer.Serialize(promotionMetadata)
});
await _db.SaveChangesAsync();
await transaction.CommitAsync();
_logger.LogInformation(
"Batch {BatchId} promoted (correction={IsCorrection}, " +
"observations={ObservationCount}, superseded={SupersededCount})",
batchId,
batch.SupersedesBatchId.HasValue,
liveObservations.Count,
supersessionResult?.ObservationsSuperseded ?? 0);
return new PromotionResult(
batch.Id,
encounterId,
liveObservations.Count,
batch.SupersedesBatchId.HasValue,
supersessionResult);
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
/// <summary>
/// Marks all live observations sourced from the original batch as superseded.
/// Records an audit event on the original batch documenting the supersession.
/// </summary>
private async Task<SupersessionResult> SupersedeOriginalBatchAsync(
Guid originalBatchId, Guid correctionBatchId, Guid actorUserId, DateTimeOffset now)
{
// Load all live observations that came from the original erroneous batch
var originalObservations = await _db.LiveObservations
.Where(o => o.SourceBatchId == originalBatchId && !o.IsSuperseded)
.ToListAsync();
if (originalObservations.Count == 0)
{
_logger.LogWarning(
"Supersession: no active live observations found for original batch {OriginalBatchId}",
originalBatchId);
}
// Mark each observation as superseded — never delete
foreach (var obs in originalObservations)
{
obs.IsSuperseded = true;
obs.SupersededByBatchId = correctionBatchId;
obs.SupersededAt = now;
}
// Count the correction batch's draft observations for the replacement count
var replacementCount = await _db.DigitizationBatches
.Where(b => b.Id == correctionBatchId)
.SelectMany(b => b.DraftObservations)
.CountAsync();
// Record supersession event on the original batch's audit trail
_db.DigitizationEvents.Add(new DigitizationEvent
{
Id = Guid.NewGuid(),
BatchId = originalBatchId,
EventType = DigitizationEventType.Superseded,
ActorUserId = actorUserId,
OccurredAt = now,
MetadataJson = JsonSerializer.Serialize(new
{
supersededByBatchId = correctionBatchId,
observationsSuperseded = originalObservations.Count,
replacementObservations = replacementCount,
reason = "Correction batch promoted — original observations marked superseded"
})
});
return new SupersessionResult(
originalBatchId,
originalObservations.Count,
replacementCount,
now);
}
/// <summary>
/// Resolves the live encounter for promotion. For correction batches, reuses the
/// encounter from the original batch to maintain continuity. For new batches,
/// creates or resolves the encounter from draft data.
/// </summary>
private async Task<Guid> ResolveEncounterAsync(DigitizationBatch batch)
{
// For correction batches, reuse the encounter from the original promoted batch
if (batch.SupersedesBatchId.HasValue)
{
var originalBatch = await _db.DigitizationBatches
.FirstOrDefaultAsync(b => b.Id == batch.SupersedesBatchId.Value);
if (originalBatch?.PromotionEncounterId.HasValue == true)
{
_logger.LogInformation(
"Correction batch {CorrectionBatchId} reusing encounter {EncounterId} " +
"from original batch {OriginalBatchId}",
batch.Id, originalBatch.PromotionEncounterId.Value, originalBatch.Id);
return originalBatch.PromotionEncounterId.Value;
}
}
// For non-correction batches, create or find the encounter from draft data
if (batch.DraftEncounter is null)
throw new ValidationException(
"Batch has no draft encounter data for promotion.",
"MISSING_ENCOUNTER_DATA");
var encounter = new LiveEncounter
{
Id = Guid.NewGuid(),
PatientId = batch.PatientId
?? throw new ValidationException("Patient ID is required for promotion.", "MISSING_PATIENT_ID"),
AdmissionDate = batch.DraftEncounter.AdmissionDate
?? throw new ValidationException("Admission date is required.", "MISSING_ADMISSION_DATE"),
Department = batch.DraftEncounter.Department,
RoomBed = batch.DraftEncounter.RoomBed,
AdmissionReason = batch.DraftEncounter.AdmissionReason,
DischargeDiagnosis = batch.DraftEncounter.DischargeDiagnosis,
Status = batch.DraftEncounter.Status ?? "active",
CreatedAt = DateTimeOffset.UtcNow
};
_db.LiveEncounters.Add(encounter);
return encounter.Id;
}
}