584 lines
22 KiB
C#
584 lines
22 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
public class DraftService : IDraftService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly ILogger<DraftService> _logger;
|
|
|
|
// Statuses that allow data entry to begin or continue
|
|
private static readonly HashSet<BatchStatus> _entryAllowedStatuses = new()
|
|
{
|
|
BatchStatus.Uploaded,
|
|
BatchStatus.InEntry,
|
|
BatchStatus.Rejected
|
|
};
|
|
|
|
public DraftService(AppDbContext db, ILogger<DraftService> logger)
|
|
{
|
|
_db = db;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<DraftPayloadResponse> GetDraftAsync(Guid batchId)
|
|
{
|
|
var batch = await _db.DigitizationBatches
|
|
.AsNoTracking()
|
|
.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");
|
|
|
|
return new DraftPayloadResponse(
|
|
batch.Id,
|
|
batch.Status.ToDbString(),
|
|
batch.BatchType.ToDbString(),
|
|
batch.DraftPatient is not null ? MapPatient(batch.DraftPatient) : null,
|
|
batch.DraftEncounter is not null ? MapEncounter(batch.DraftEncounter) : null,
|
|
batch.DraftObservations.Select(MapObservation).OrderBy(o => o.RecordedAt).ToList()
|
|
);
|
|
}
|
|
|
|
public async Task<DraftPatientDto> UpsertPatientAsync(
|
|
Guid batchId, UpsertDraftPatientRequest req, Guid actorUserId)
|
|
{
|
|
var batch = await LoadBatchForEntryAsync(batchId, actorUserId);
|
|
TransitionToInEntryIfNeeded(batch, actorUserId);
|
|
|
|
BloodType? parsedBloodType = null;
|
|
if (req.BloodType is not null)
|
|
{
|
|
if (!BloodTypeExtensions.TryFromDbString(req.BloodType, out var parsed))
|
|
throw new ValidationException(
|
|
$"Invalid blood type '{req.BloodType}'. Allowed values: A+, A-, B+, B-, AB+, AB-, O+, O-.",
|
|
"INVALID_BLOOD_TYPE");
|
|
parsedBloodType = parsed;
|
|
}
|
|
|
|
var patient = await _db.DraftPatients.FirstOrDefaultAsync(p => p.BatchId == batchId);
|
|
|
|
if (patient is null)
|
|
{
|
|
patient = new DraftPatient
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
FullName = req.FullName,
|
|
DateOfBirth = req.DateOfBirth,
|
|
Sex = req.Sex,
|
|
BloodType = parsedBloodType,
|
|
EmergencyContact = req.EmergencyContact,
|
|
AllergiesJson = req.Allergies is not null
|
|
? JsonSerializer.Serialize(req.Allergies)
|
|
: null,
|
|
NoKnownAllergies = req.NoKnownAllergies,
|
|
MedicationsJson = req.Medications is not null
|
|
? JsonSerializer.Serialize(req.Medications)
|
|
: null,
|
|
NoActiveMedications = req.NoActiveMedications,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
_db.DraftPatients.Add(patient);
|
|
}
|
|
else
|
|
{
|
|
patient.FullName = req.FullName;
|
|
patient.DateOfBirth = req.DateOfBirth;
|
|
patient.Sex = req.Sex;
|
|
patient.BloodType = parsedBloodType;
|
|
patient.EmergencyContact = req.EmergencyContact;
|
|
patient.AllergiesJson = req.Allergies is not null
|
|
? JsonSerializer.Serialize(req.Allergies)
|
|
: null;
|
|
patient.NoKnownAllergies = req.NoKnownAllergies;
|
|
patient.MedicationsJson = req.Medications is not null
|
|
? JsonSerializer.Serialize(req.Medications)
|
|
: null;
|
|
patient.NoActiveMedications = req.NoActiveMedications;
|
|
patient.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
_logger.LogInformation(
|
|
"Draft patient upserted for batch {BatchId} by user {UserId}",
|
|
batchId, actorUserId);
|
|
|
|
return MapPatient(patient);
|
|
}
|
|
|
|
public async Task<DraftEncounterDto> UpsertEncounterAsync(
|
|
Guid batchId, UpsertDraftEncounterRequest req, Guid actorUserId)
|
|
{
|
|
var batch = await LoadBatchForEntryAsync(batchId, actorUserId);
|
|
TransitionToInEntryIfNeeded(batch, actorUserId);
|
|
|
|
Department? parsedDepartment = null;
|
|
if (req.Department is not null)
|
|
{
|
|
if (!DepartmentExtensions.TryFromDbString(req.Department, out var parsed))
|
|
throw new ValidationException(
|
|
$"Invalid department '{req.Department}'. Must be a recognized hospital department.",
|
|
"INVALID_DEPARTMENT");
|
|
parsedDepartment = parsed;
|
|
}
|
|
|
|
var encounter = await _db.DraftEncounters.FirstOrDefaultAsync(e => e.BatchId == batchId);
|
|
|
|
if (encounter is null)
|
|
{
|
|
encounter = new DraftEncounter
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
AdmissionDate = req.AdmissionDate,
|
|
Department = parsedDepartment,
|
|
RoomBed = req.RoomBed,
|
|
AdmissionReason = req.AdmissionReason,
|
|
DischargeDiagnosis = req.DischargeDiagnosis,
|
|
Status = req.Status,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
_db.DraftEncounters.Add(encounter);
|
|
}
|
|
else
|
|
{
|
|
encounter.AdmissionDate = req.AdmissionDate;
|
|
encounter.Department = parsedDepartment;
|
|
encounter.RoomBed = req.RoomBed;
|
|
encounter.AdmissionReason = req.AdmissionReason;
|
|
encounter.DischargeDiagnosis = req.DischargeDiagnosis;
|
|
encounter.Status = req.Status;
|
|
encounter.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
_logger.LogInformation(
|
|
"Draft encounter upserted for batch {BatchId} by user {UserId}",
|
|
batchId, actorUserId);
|
|
|
|
return MapEncounter(encounter);
|
|
}
|
|
|
|
public async Task<DraftObservationDto> AddObservationAsync(
|
|
Guid batchId, CreateDraftObservationRequest req, Guid actorUserId)
|
|
{
|
|
var batch = await LoadBatchForEntryAsync(batchId, actorUserId);
|
|
TransitionToInEntryIfNeeded(batch, actorUserId);
|
|
|
|
// Plausibility check — reject impossible values before persisting
|
|
if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason))
|
|
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
|
|
|
var observation = new DraftObservation
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
ObservationCode = req.ObservationCode,
|
|
Value = req.Value,
|
|
Unit = req.Unit,
|
|
RecordedAt = req.RecordedAt,
|
|
Note = req.Note,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
_db.DraftObservations.Add(observation);
|
|
await _db.SaveChangesAsync();
|
|
|
|
_logger.LogInformation(
|
|
"Draft observation {ObservationId} added to batch {BatchId} by user {UserId}",
|
|
observation.Id, batchId, actorUserId);
|
|
|
|
return MapObservation(observation);
|
|
}
|
|
|
|
public async Task<DraftObservationDto> UpdateObservationAsync(
|
|
Guid batchId, Guid observationId, UpdateDraftObservationRequest req, Guid actorUserId)
|
|
{
|
|
var batch = await LoadBatchForEntryAsync(batchId, actorUserId);
|
|
|
|
var observation = await _db.DraftObservations
|
|
.FirstOrDefaultAsync(o => o.Id == observationId && o.BatchId == batchId);
|
|
|
|
if (observation is null)
|
|
throw new NotFoundException(
|
|
"Observation not found in this batch.", "OBSERVATION_NOT_FOUND");
|
|
|
|
// Re-validate plausibility on the new value
|
|
if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason))
|
|
throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE");
|
|
|
|
observation.ObservationCode = req.ObservationCode;
|
|
observation.Value = req.Value;
|
|
observation.Unit = req.Unit;
|
|
observation.RecordedAt = req.RecordedAt;
|
|
observation.Note = req.Note;
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
_logger.LogInformation(
|
|
"Draft observation {ObservationId} updated in batch {BatchId} by user {UserId}",
|
|
observationId, batchId, actorUserId);
|
|
|
|
return MapObservation(observation);
|
|
}
|
|
|
|
public async Task DeleteObservationAsync(
|
|
Guid batchId, Guid observationId, Guid actorUserId)
|
|
{
|
|
var batch = await LoadBatchForEntryAsync(batchId, actorUserId);
|
|
|
|
var observation = await _db.DraftObservations
|
|
.FirstOrDefaultAsync(o => o.Id == observationId && o.BatchId == batchId);
|
|
|
|
if (observation is null)
|
|
throw new NotFoundException(
|
|
"Observation not found in this batch.", "OBSERVATION_NOT_FOUND");
|
|
|
|
_db.DraftObservations.Remove(observation);
|
|
await _db.SaveChangesAsync();
|
|
|
|
_logger.LogInformation(
|
|
"Draft observation {ObservationId} deleted from batch {BatchId} by user {UserId}",
|
|
observationId, batchId, actorUserId);
|
|
}
|
|
|
|
public async Task<DigitizationBatch> SubmitForVerificationAsync(
|
|
Guid batchId, Guid actorUserId)
|
|
{
|
|
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.InEntry)
|
|
throw new ConflictException(
|
|
$"Only batches in 'in_entry' status can be submitted for verification. " +
|
|
$"Current status: '{batch.Status.ToDbString()}'.",
|
|
"ILLEGAL_STATUS_TRANSITION");
|
|
|
|
// Batch-type-specific completeness validation
|
|
ValidateCompleteness(batch);
|
|
|
|
// Transition to PendingVerification
|
|
batch.Status = BatchStatus.PendingVerification;
|
|
batch.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
_db.DigitizationEvents.Add(new DigitizationEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batchId,
|
|
EventType = DigitizationEventType.SubmittedForVerification,
|
|
ActorUserId = actorUserId,
|
|
OccurredAt = DateTimeOffset.UtcNow,
|
|
MetadataJson = JsonSerializer.Serialize(new
|
|
{
|
|
batchType = batch.BatchType.ToDbString(),
|
|
observationCount = batch.DraftObservations.Count
|
|
})
|
|
});
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
_logger.LogInformation(
|
|
"Batch {BatchId} submitted for verification by user {UserId}. " +
|
|
"Type={BatchType}, ObservationCount={ObsCount}",
|
|
batchId, actorUserId,
|
|
batch.BatchType.ToDbString(),
|
|
batch.DraftObservations.Count);
|
|
|
|
return batch;
|
|
}
|
|
|
|
// ─── Private helpers ─────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Loads the batch and validates that its current status allows data entry.
|
|
/// Enforces assignment: only the assigned entry clerk (enteredByUserId) or an
|
|
/// administrator may save draft fields. Throws BATCH_NOT_ASSIGNED otherwise.
|
|
/// </summary>
|
|
private async Task<DigitizationBatch> LoadBatchForEntryAsync(Guid batchId, Guid actorUserId)
|
|
{
|
|
var batch = await _db.DigitizationBatches.FindAsync(batchId);
|
|
|
|
if (batch is null)
|
|
throw new NotFoundException("Batch not found.", "BATCH_NOT_FOUND");
|
|
|
|
if (!_entryAllowedStatuses.Contains(batch.Status))
|
|
throw new ConflictException(
|
|
$"Data entry is not allowed for batches in '{batch.Status.ToDbString()}' status. " +
|
|
$"Allowed statuses: UPLOADED, IN_ENTRY, REJECTED.",
|
|
"ENTRY_NOT_ALLOWED");
|
|
|
|
if (batch.EnteredByUserId.HasValue && batch.EnteredByUserId.Value != actorUserId)
|
|
{
|
|
var actor = await _db.Users.AsNoTracking()
|
|
.FirstOrDefaultAsync(u => u.Id == actorUserId);
|
|
if (actor?.Role != UserRole.Administrator)
|
|
{
|
|
throw new ConflictException(
|
|
"This batch is assigned to another entry clerk.",
|
|
"BATCH_NOT_ASSIGNED");
|
|
}
|
|
}
|
|
|
|
return batch;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transitions the batch from Uploaded or Rejected to InEntry on first save.
|
|
/// If the batch is already InEntry, this is a no-op.
|
|
/// </summary>
|
|
private void TransitionToInEntryIfNeeded(DigitizationBatch batch, Guid actorUserId)
|
|
{
|
|
if (batch.Status == BatchStatus.InEntry) return;
|
|
|
|
// batch.Status is Uploaded or Rejected (validated by LoadBatchForEntryAsync)
|
|
var previousStatus = batch.Status.ToDbString();
|
|
batch.Status = BatchStatus.InEntry;
|
|
batch.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
_db.DigitizationEvents.Add(new DigitizationEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
BatchId = batch.Id,
|
|
EventType = DigitizationEventType.EntryStarted,
|
|
ActorUserId = actorUserId,
|
|
OccurredAt = DateTimeOffset.UtcNow,
|
|
MetadataJson = JsonSerializer.Serialize(new
|
|
{
|
|
previousStatus,
|
|
newStatus = "IN_ENTRY"
|
|
})
|
|
});
|
|
|
|
_logger.LogInformation(
|
|
"Batch {BatchId} transitioned from {PreviousStatus} to IN_ENTRY",
|
|
batch.Id, previousStatus);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates batch-type-specific completeness rules. Throws ValidationException
|
|
/// with a descriptive message listing all missing fields.
|
|
/// </summary>
|
|
private static void ValidateCompleteness(DigitizationBatch batch)
|
|
{
|
|
var errors = new List<string>();
|
|
|
|
switch (batch.BatchType)
|
|
{
|
|
case BatchType.PatientRegistration:
|
|
ValidatePatientRegistration(batch, errors);
|
|
break;
|
|
|
|
case BatchType.VitalsSheet:
|
|
ValidateVitalsSheet(batch, errors);
|
|
break;
|
|
|
|
case BatchType.LabResults:
|
|
ValidateLabResults(batch, errors);
|
|
break;
|
|
|
|
case BatchType.AllergyUpdate:
|
|
ValidateAllergyUpdate(batch, errors);
|
|
break;
|
|
|
|
case BatchType.EncounterSummary:
|
|
ValidateEncounterSummary(batch, errors);
|
|
break;
|
|
|
|
case BatchType.MedicationList:
|
|
ValidateMedicationList(batch, errors);
|
|
break;
|
|
|
|
case BatchType.Mixed:
|
|
ValidateMixed(batch, errors);
|
|
break;
|
|
}
|
|
|
|
if (errors.Count > 0)
|
|
{
|
|
var message = $"Batch is incomplete for type '{batch.BatchType.ToDbString()}'. " +
|
|
$"Missing: {string.Join("; ", errors)}.";
|
|
throw new ValidationException(message, "BATCH_INCOMPLETE");
|
|
}
|
|
}
|
|
|
|
private static void ValidatePatientRegistration(
|
|
DigitizationBatch batch, List<string> errors)
|
|
{
|
|
// Required: Full name, date of birth, sex
|
|
if (batch.DraftPatient is null)
|
|
{
|
|
errors.Add("Patient demographics are required");
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(batch.DraftPatient.FullName))
|
|
errors.Add("Patient full name is required");
|
|
|
|
if (batch.DraftPatient.DateOfBirth is null)
|
|
errors.Add("Patient date of birth is required");
|
|
|
|
if (string.IsNullOrWhiteSpace(batch.DraftPatient.Sex))
|
|
errors.Add("Patient sex is required");
|
|
}
|
|
|
|
private static void ValidateVitalsSheet(
|
|
DigitizationBatch batch, List<string> errors)
|
|
{
|
|
// Required: Linked patient, encounter context, >= 1 observation with recordedAt
|
|
if (batch.DraftPatient is null)
|
|
errors.Add("Linked patient is required for vitals sheets");
|
|
|
|
if (batch.DraftEncounter is null)
|
|
errors.Add("Encounter context is required for vitals sheets");
|
|
|
|
if (batch.DraftObservations.Count == 0)
|
|
errors.Add("At least one observation with a recorded timestamp is required");
|
|
else if (batch.DraftObservations.Any(o => o.RecordedAt == default))
|
|
errors.Add("All observations must have a recorded timestamp");
|
|
}
|
|
|
|
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");
|
|
|
|
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");
|
|
else if (batch.DraftObservations.Any(o => o.RecordedAt == default))
|
|
errors.Add("All observations must have a recorded timestamp");
|
|
}
|
|
|
|
private static void ValidateAllergyUpdate(
|
|
DigitizationBatch batch, List<string> errors)
|
|
{
|
|
// Required: Linked patient, allergies list (may be empty with noKnownAllergies: true)
|
|
if (batch.DraftPatient is null)
|
|
{
|
|
errors.Add("Linked patient is required for allergy updates");
|
|
return;
|
|
}
|
|
|
|
var hasAllergies = !string.IsNullOrWhiteSpace(batch.DraftPatient.AllergiesJson);
|
|
var hasNoKnownAllergiesFlag = batch.DraftPatient.NoKnownAllergies;
|
|
|
|
// Either the allergies list must be present OR noKnownAllergies must be true
|
|
if (!hasAllergies && !hasNoKnownAllergiesFlag)
|
|
errors.Add("Allergies list is required (set noKnownAllergies to true if none)");
|
|
}
|
|
|
|
private static void ValidateEncounterSummary(
|
|
DigitizationBatch batch, List<string> errors)
|
|
{
|
|
if (batch.DraftPatient is null)
|
|
errors.Add("Linked patient is required for encounter summaries");
|
|
|
|
if (batch.DraftEncounter is null)
|
|
{
|
|
errors.Add("Encounter context is required for encounter summaries");
|
|
return;
|
|
}
|
|
|
|
if (batch.DraftEncounter.AdmissionDate is null)
|
|
errors.Add("Admission date is required for encounter summaries");
|
|
|
|
if (batch.DraftEncounter.Department is null)
|
|
errors.Add("Department is required for encounter summaries");
|
|
|
|
if (string.IsNullOrWhiteSpace(batch.DraftEncounter.AdmissionReason))
|
|
errors.Add("Admission reason is required for encounter summaries");
|
|
}
|
|
|
|
private static void ValidateMedicationList(
|
|
DigitizationBatch batch, List<string> errors)
|
|
{
|
|
if (batch.DraftPatient is null)
|
|
{
|
|
errors.Add("Linked patient is required for medication lists");
|
|
return;
|
|
}
|
|
|
|
var hasMedications = !string.IsNullOrWhiteSpace(batch.DraftPatient.MedicationsJson);
|
|
if (!hasMedications && !batch.DraftPatient.NoActiveMedications)
|
|
errors.Add("Medications list is required (set noActiveMedications to true if none)");
|
|
}
|
|
|
|
private static void ValidateMixed(
|
|
DigitizationBatch batch, List<string> errors)
|
|
{
|
|
if (batch.DraftPatient is null)
|
|
errors.Add("Linked patient is required for mixed batches");
|
|
|
|
if (batch.DraftEncounter is null)
|
|
errors.Add("Encounter context is required for mixed batches");
|
|
|
|
var hasObservations = batch.DraftObservations.Count > 0
|
|
&& batch.DraftObservations.All(o => o.RecordedAt != default);
|
|
|
|
var hasEncounterSummary = batch.DraftEncounter is not null
|
|
&& batch.DraftEncounter.AdmissionDate is not null
|
|
&& batch.DraftEncounter.Department is not null
|
|
&& !string.IsNullOrWhiteSpace(batch.DraftEncounter.AdmissionReason);
|
|
|
|
if (!hasObservations && !hasEncounterSummary)
|
|
errors.Add("Mixed batch requires at least one observation with recordedAt, or a complete encounter summary (admission date, department, admission reason)");
|
|
}
|
|
|
|
// ─── Mapping helpers ─────────────────────────────────────────
|
|
|
|
private static DraftPatientDto MapPatient(DraftPatient p) => new(
|
|
p.Id,
|
|
p.FullName,
|
|
p.DateOfBirth,
|
|
p.Sex,
|
|
p.BloodType?.ToDbString(),
|
|
p.EmergencyContact,
|
|
!string.IsNullOrWhiteSpace(p.AllergiesJson)
|
|
? JsonSerializer.Deserialize<List<string>>(p.AllergiesJson)
|
|
: null,
|
|
p.NoKnownAllergies,
|
|
!string.IsNullOrWhiteSpace(p.MedicationsJson)
|
|
? JsonSerializer.Deserialize<List<string>>(p.MedicationsJson)
|
|
: null,
|
|
p.NoActiveMedications,
|
|
p.UpdatedAt
|
|
);
|
|
|
|
private static DraftEncounterDto MapEncounter(DraftEncounter e) => new(
|
|
e.Id,
|
|
e.AdmissionDate,
|
|
e.Department?.ToDbString(),
|
|
e.RoomBed,
|
|
e.AdmissionReason,
|
|
e.DischargeDiagnosis,
|
|
e.Status,
|
|
e.UpdatedAt
|
|
);
|
|
|
|
private static DraftObservationDto MapObservation(DraftObservation o) => new(
|
|
o.Id,
|
|
o.ObservationCode,
|
|
o.Value,
|
|
o.Unit,
|
|
o.RecordedAt,
|
|
o.Note,
|
|
o.CreatedAt
|
|
);
|
|
} |