feature: Draft Data Entry

This commit is contained in:
voltsrage
2026-06-26 05:04:39 +08:00
parent f74f8f471f
commit 7121520926
20 changed files with 2033 additions and 2 deletions
@@ -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;
}