feature: Draft Data Entry
This commit is contained in:
@@ -0,0 +1,584 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
public interface IDraftService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the full draft payload for a batch, including patient, encounter,
|
||||
/// and all observation rows entered so far.
|
||||
/// </summary>
|
||||
Task<DraftPayloadResponse> GetDraftAsync(Guid batchId);
|
||||
|
||||
/// <summary>
|
||||
/// Upserts draft patient demographics for a batch. Creates the DraftPatient
|
||||
/// row on the first call; updates it on subsequent calls. Transitions batch
|
||||
/// from Uploaded/Rejected to InEntry on first save.
|
||||
/// </summary>
|
||||
Task<DraftPatientDto> UpsertPatientAsync(Guid batchId, UpsertDraftPatientRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Upserts draft encounter fields for a batch. Creates the DraftEncounter
|
||||
/// row on the first call; updates it on subsequent calls.
|
||||
/// </summary>
|
||||
Task<DraftEncounterDto> UpsertEncounterAsync(Guid batchId, UpsertDraftEncounterRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new observation row to the batch draft. Validates plausibility
|
||||
/// before saving — implausible values throw ValidationException.
|
||||
/// </summary>
|
||||
Task<DraftObservationDto> AddObservationAsync(Guid batchId, CreateDraftObservationRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Edits an existing observation row. Re-validates plausibility on the new value.
|
||||
/// </summary>
|
||||
Task<DraftObservationDto> UpdateObservationAsync(Guid batchId, Guid observationId, UpdateDraftObservationRequest req, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an observation row from the batch draft.
|
||||
/// </summary>
|
||||
Task DeleteObservationAsync(Guid batchId, Guid observationId, Guid actorUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Validates completeness per batch type and transitions the batch to
|
||||
/// PendingVerification. Throws ValidationException if required fields
|
||||
/// are missing.
|
||||
/// </summary>
|
||||
Task<DigitizationBatch> SubmitForVerificationAsync(Guid batchId, Guid actorUserId);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
public static class PlausibilityValidator
|
||||
{
|
||||
// Plausible ranges define the outer boundary of physically possible values.
|
||||
// These are NOT clinical alert thresholds — they catch device malfunctions,
|
||||
// transcription errors, and misread handwriting from paper charts.
|
||||
// A heart rate of 300 is clinically extreme but not impossible during VT;
|
||||
// 400 is physically impossible and indicates a data entry mistake.
|
||||
private static readonly Dictionary<string, (decimal Min, decimal Max)> _ranges = new()
|
||||
{
|
||||
["HEART_RATE"] = (1, 300), // beats per minute; ceiling allows extreme tachycardia (e.g. VT)
|
||||
["TEMP_C"] = (15, 50), // core body temperature in °C
|
||||
["POTASSIUM_MEQ_L"] = (0.1m, 12), // serum potassium mEq/L; catches decimal misplacement (5.2 vs 52)
|
||||
["SPO2"] = (50, 100), // peripheral oxygen saturation %
|
||||
["RESP_RATE"] = (1, 80), // respirations per minute
|
||||
["WBC_K_UL"] = (0.1m, 500), // white blood cell count ×10³/µL
|
||||
["GLUCOSE_MG_DL"] = (10, 1000), // blood glucose mg/dL
|
||||
["LACTATE_MMOL_L"] = (0.1m, 30), // blood lactate mmol/L
|
||||
["BP_SYSTOLIC"] = (40, 300), // systolic blood pressure mmHg
|
||||
["BP_DIASTOLIC"] = (20, 200), // diastolic blood pressure mmHg
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the value falls within the plausible range for the given code.
|
||||
/// Unknown observation codes pass plausibility — the code is validated elsewhere.
|
||||
/// </summary>
|
||||
public static bool IsPlausible(string observationCode, decimal value, out string? reason)
|
||||
{
|
||||
if (!_ranges.TryGetValue(observationCode, out var range))
|
||||
{
|
||||
// Unknown codes pass plausibility — the observation code itself
|
||||
// is validated at the business rule layer, not here.
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value < range.Min || value > range.Max)
|
||||
{
|
||||
reason = $"Value {value} is outside the plausible range " +
|
||||
$"[{range.Min}–{range.Max}] for {observationCode}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all known observation codes and their plausible ranges.
|
||||
/// Used by the frontend to display valid input boundaries.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, (decimal Min, decimal Max)> GetAllRanges() =>
|
||||
_ranges;
|
||||
}
|
||||
Reference in New Issue
Block a user