Rebuild CCS case workflow
This commit is contained in:
@@ -51,6 +51,16 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
}
|
||||
|
||||
[HttpPost("attempts/{id}/commands/batch")]
|
||||
public async Task<IActionResult> BatchCommand(string id, CcsBatchCommandRequest request)
|
||||
{
|
||||
var attempt = await store.GetOwnedAsync(id, UserId());
|
||||
if (attempt is null) return NotFound();
|
||||
try { return Ok(await store.ToResponseAsync(await store.ApplyBatchCommandAsync(attempt, request))); }
|
||||
catch (ArgumentException exception) { return BadRequest(exception.Message); }
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
}
|
||||
|
||||
[HttpPost("attempts/{id}/abandon")]
|
||||
public async Task<IActionResult> Abandon(string id)
|
||||
{
|
||||
|
||||
@@ -17,6 +17,26 @@ public static class CcsAttemptModes
|
||||
public const string Timed = "timed";
|
||||
}
|
||||
|
||||
public static class CcsChartCategories
|
||||
{
|
||||
public const string Orders = "orders";
|
||||
public const string Progress = "progress";
|
||||
public const string Vitals = "vitals";
|
||||
public const string Lab = "lab";
|
||||
public const string Imaging = "imaging";
|
||||
public const string OtherTest = "other_test";
|
||||
public const string Treatment = "treatment";
|
||||
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
Orders, Progress, Vitals, Lab, Imaging, OtherTest, Treatment
|
||||
};
|
||||
public static readonly IReadOnlySet<string> ResultSections = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
Lab, Imaging, OtherTest, Treatment
|
||||
};
|
||||
}
|
||||
|
||||
public class CcsOrderDefinition
|
||||
{
|
||||
[BsonId] public string Code { get; set; } = "";
|
||||
@@ -25,6 +45,9 @@ public class CcsOrderDefinition
|
||||
public List<string> Aliases { get; set; } = [];
|
||||
public int DefaultTurnaroundMinutes { get; set; }
|
||||
public List<string> AvailableLocations { get; set; } = [];
|
||||
public string DisplayRoute { get; set; } = "";
|
||||
public string DisplayFrequency { get; set; } = "";
|
||||
public string ResultSection { get; set; } = "";
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -57,11 +80,19 @@ public class CcsInitialChart
|
||||
public string ReasonForEncounter { get; set; } = "";
|
||||
public string Appearance { get; set; } = "";
|
||||
public string History { get; set; } = "";
|
||||
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
|
||||
public Dictionary<string, string> Vitals { get; set; } = [];
|
||||
public DateTime SimulatedStartUtc { get; set; } = DateTime.UtcNow;
|
||||
public List<string> InitialOrderCodes { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsNarrativeSection
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string Text { get; set; } = "";
|
||||
}
|
||||
|
||||
public class CcsExamDefinition
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
@@ -75,6 +106,7 @@ public class CcsCaseOrderDefinition
|
||||
public string OrderCode { get; set; } = "";
|
||||
public int? TurnaroundMinutes { get; set; }
|
||||
public string Result { get; set; } = "";
|
||||
public string ResultSection { get; set; } = "";
|
||||
public Dictionary<string, string> VitalPatches { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -111,19 +143,22 @@ public class CcsAttempt
|
||||
public string CaseExternalId { get; set; } = "";
|
||||
[BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = "";
|
||||
public int CaseVersion { get; set; }
|
||||
public string Mode { get; set; } = CcsAttemptModes.Practice;
|
||||
public string Mode { get; set; } = CcsAttemptModes.Timed;
|
||||
public string State { get; set; } = CcsAttemptStates.Active;
|
||||
public int Revision { get; set; }
|
||||
public int SimulatedMinute { get; set; }
|
||||
[BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; }
|
||||
public string Location { get; set; } = "";
|
||||
public Dictionary<string, string> CurrentVitals { get; set; } = [];
|
||||
public List<CcsAttemptAction> Actions { get; set; } = [];
|
||||
public List<CcsPendingResult> PendingResults { get; set; } = [];
|
||||
public List<CcsChartEntry> Chart { get; set; } = [];
|
||||
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
|
||||
public List<string> ActiveOrderCodes { get; set; } = [];
|
||||
public List<string> ProcessedEventIds { get; set; } = [];
|
||||
public List<string> ProcessedCommandIds { get; set; } = [];
|
||||
public DateTime? DeadlineUtc { get; set; }
|
||||
public DateTime? HardDeadlineUtc { get; set; }
|
||||
public DateTime? FinalOrdersDeadlineUtc { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
@@ -144,6 +179,8 @@ public class CcsAttemptAction
|
||||
public class CcsPendingResult
|
||||
{
|
||||
public string OrderCode { get; set; } = "";
|
||||
public string Category { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public int DueMinute { get; set; }
|
||||
public string Result { get; set; } = "";
|
||||
public Dictionary<string, string> VitalPatches { get; set; } = [];
|
||||
@@ -153,9 +190,30 @@ public class CcsChartEntry
|
||||
{
|
||||
public string Category { get; set; } = "progress";
|
||||
public int SimulatedMinute { get; set; }
|
||||
public string Code { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string Text { get; set; } = "";
|
||||
}
|
||||
|
||||
public class CcsVitalSnapshot
|
||||
{
|
||||
public int SimulatedMinute { get; set; }
|
||||
public Dictionary<string, string> Values { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsActiveOrder
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Category { get; set; } = "";
|
||||
public string Route { get; set; } = "";
|
||||
public string Frequency { get; set; } = "";
|
||||
public int OrderedMinute { get; set; }
|
||||
public int? ReportMinute { get; set; }
|
||||
public bool IsPending { get; set; }
|
||||
public bool HasResult { get; set; }
|
||||
}
|
||||
|
||||
public class CcsCaseScore
|
||||
{
|
||||
public double Percent { get; set; }
|
||||
@@ -194,7 +252,7 @@ public class CcsImportResponse
|
||||
public class CreateCcsAttemptRequest
|
||||
{
|
||||
public string CaseExternalId { get; set; } = "";
|
||||
public string Mode { get; set; } = CcsAttemptModes.Practice;
|
||||
public string Mode { get; set; } = CcsAttemptModes.Timed;
|
||||
}
|
||||
|
||||
public class CcsCommandRequest
|
||||
@@ -206,6 +264,20 @@ public class CcsCommandRequest
|
||||
public int Minutes { get; set; }
|
||||
}
|
||||
|
||||
public class CcsBatchCommandRequest
|
||||
{
|
||||
public string CommandId { get; set; } = "";
|
||||
public int ExpectedRevision { get; set; }
|
||||
public List<CcsBatchCommandItem> Commands { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsBatchCommandItem
|
||||
{
|
||||
public string Type { get; set; } = "";
|
||||
public string Code { get; set; } = "";
|
||||
public int Minutes { get; set; }
|
||||
}
|
||||
|
||||
public class CcsCaseSummary
|
||||
{
|
||||
public string ExternalId { get; set; } = "";
|
||||
@@ -226,8 +298,11 @@ public class CcsAttemptResponse
|
||||
public string State { get; set; } = "";
|
||||
public int Revision { get; set; }
|
||||
public int SimulatedMinute { get; set; }
|
||||
public DateTime SimulatedStartUtc { get; set; }
|
||||
public bool OpeningCompleted { get; set; }
|
||||
public string Location { get; set; } = "";
|
||||
public DateTime? DeadlineUtc { get; set; }
|
||||
public DateTime? HardDeadlineUtc { get; set; }
|
||||
public DateTime? FinalOrdersDeadlineUtc { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public int RealTimeMinutes { get; set; }
|
||||
@@ -236,9 +311,12 @@ public class CcsAttemptResponse
|
||||
public string ReasonForEncounter { get; set; } = "";
|
||||
public string Appearance { get; set; } = "";
|
||||
public string History { get; set; } = "";
|
||||
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
|
||||
public Dictionary<string, string> Vitals { get; set; } = [];
|
||||
public List<CcsChartEntry> Chart { get; set; } = [];
|
||||
public List<string> ActiveOrderCodes { get; set; } = [];
|
||||
public List<CcsActiveOrder> ActiveOrders { get; set; } = [];
|
||||
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
|
||||
public List<CcsPendingResult> PendingResults { get; set; } = [];
|
||||
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
|
||||
public List<string> AllowedLocations { get; set; } = [];
|
||||
|
||||
@@ -4,8 +4,7 @@ namespace QuestionsApi.Services;
|
||||
|
||||
public static class CcsCaseValidator
|
||||
{
|
||||
private static readonly HashSet<string> Modes = [CcsAttemptModes.Practice, CcsAttemptModes.Timed];
|
||||
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "end_case", "exit_case"];
|
||||
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"];
|
||||
|
||||
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
|
||||
{
|
||||
@@ -18,6 +17,8 @@ public static class CcsCaseValidator
|
||||
if (string.IsNullOrWhiteSpace(order.CanonicalName)) errors.Add(prefix + "canonicalName required");
|
||||
if (string.IsNullOrWhiteSpace(order.Category)) errors.Add(prefix + "category required");
|
||||
if (order.DefaultTurnaroundMinutes < 0) errors.Add(prefix + "defaultTurnaroundMinutes cannot be negative");
|
||||
if (!string.IsNullOrWhiteSpace(order.ResultSection) && !CcsChartCategories.ResultSections.Contains(order.ResultSection))
|
||||
errors.Add(prefix + "resultSection is invalid");
|
||||
}
|
||||
if (request.Orders.Select(order => order.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Orders.Count)
|
||||
errors.Add("order codes must be unique within an import");
|
||||
@@ -50,7 +51,11 @@ public static class CcsCaseValidator
|
||||
if (eventIds.Any(string.IsNullOrWhiteSpace) || eventIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != eventIds.Count)
|
||||
errors.Add(prefix + "event ids must be present and unique");
|
||||
foreach (var response in item.OrderResponses)
|
||||
{
|
||||
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
|
||||
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
|
||||
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
|
||||
}
|
||||
foreach (var code in item.InitialChart.InitialOrderCodes.Concat(item.Events.SelectMany(value => value.RequiresOrderCodes.Concat(value.RequiresAnyOrderCodes))))
|
||||
if (!orderCodes.Contains(code)) errors.Add(prefix + $"unknown order code {code}");
|
||||
var criteriaIds = item.ScoringCriteria.Select(value => value.Id).ToList();
|
||||
@@ -75,7 +80,8 @@ public static class CcsCaseValidator
|
||||
|
||||
public static string? ValidateCreate(CreateCcsAttemptRequest request)
|
||||
{
|
||||
if (!Modes.Contains(request.Mode)) return "mode must be practice or timed";
|
||||
if (!request.Mode.Equals(CcsAttemptModes.Timed, StringComparison.OrdinalIgnoreCase))
|
||||
return "new CCS attempts must be timed";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -88,4 +94,29 @@ public static class CcsCaseValidator
|
||||
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? ValidateBatch(CcsBatchCommandRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
|
||||
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
|
||||
if (request.Commands.Count is < 1 or > 50) return "commands must contain between 1 and 50 items";
|
||||
foreach (var item in request.Commands)
|
||||
{
|
||||
var error = ValidateCommand(new CcsCommandRequest
|
||||
{
|
||||
CommandId = request.CommandId,
|
||||
ExpectedRevision = request.ExpectedRevision,
|
||||
Type = item.Type,
|
||||
Code = item.Code,
|
||||
Minutes = item.Minutes
|
||||
});
|
||||
if (error is not null) return error;
|
||||
if (item.Type is not ("exam" or "interval_history" or "order" or "cancel_order"))
|
||||
return "batch commands support history/exams, orders, and order cancellations only";
|
||||
}
|
||||
var hasClinicalAssessment = request.Commands.Any(value => value.Type is "exam" or "interval_history");
|
||||
if (hasClinicalAssessment && request.Commands.Any(value => value.Type is not ("exam" or "interval_history")))
|
||||
return "history and examinations cannot be mixed with order changes in one batch";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,16 +115,22 @@ public sealed class CcsStore
|
||||
CaseExternalId = definition.ExternalId,
|
||||
CaseRevisionId = definition.Id ?? "",
|
||||
CaseVersion = definition.Version,
|
||||
Mode = request.Mode,
|
||||
Mode = CcsAttemptModes.Timed,
|
||||
OpeningCompleted = false,
|
||||
Location = definition.PresentingLocation,
|
||||
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
|
||||
VitalHistory =
|
||||
[
|
||||
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
|
||||
],
|
||||
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
DeadlineUtc = request.Mode == CcsAttemptModes.Timed ? now.AddMinutes(definition.RealTimeMinutes) : null,
|
||||
RatingEligible = request.Mode == CcsAttemptModes.Timed,
|
||||
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
|
||||
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
|
||||
RatingEligible = true,
|
||||
Chart =
|
||||
[
|
||||
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter },
|
||||
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance }
|
||||
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
|
||||
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "initial_appearance", Title = "Initial appearance", Text = definition.InitialChart.Appearance }
|
||||
]
|
||||
};
|
||||
try { await _attempts.InsertOneAsync(attempt); }
|
||||
@@ -173,7 +179,12 @@ public sealed class CcsStore
|
||||
if (attempt.State == CcsAttemptStates.Active && attempt.DeadlineUtc <= DateTime.UtcNow)
|
||||
{
|
||||
attempt.State = CcsAttemptStates.FinalOrders;
|
||||
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.AddMinutes(2);
|
||||
attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc);
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions",
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders."
|
||||
});
|
||||
attempt.Revision++;
|
||||
await SaveAsync(attempt);
|
||||
}
|
||||
@@ -206,9 +217,16 @@ public sealed class CcsStore
|
||||
case "advance": Advance(attempt, definition, command.Minutes); break;
|
||||
case "interval_history":
|
||||
attempt.Actions.Add(Action("interval_history", "interval_history", attempt.SimulatedMinute, now));
|
||||
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." });
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
|
||||
});
|
||||
Advance(attempt, definition, 1);
|
||||
break;
|
||||
case "acknowledge_opening":
|
||||
attempt.OpeningCompleted = true;
|
||||
break;
|
||||
case "end_case": EnterFinalOrders(attempt); break;
|
||||
case "exit_case": await CompleteAsync(attempt); break;
|
||||
}
|
||||
@@ -220,6 +238,63 @@ public sealed class CcsStore
|
||||
return attempt;
|
||||
}
|
||||
|
||||
public async Task<CcsAttempt> ApplyBatchCommandAsync(CcsAttempt attempt, CcsBatchCommandRequest request)
|
||||
{
|
||||
var validation = CcsCaseValidator.ValidateBatch(request);
|
||||
if (validation is not null) throw new ArgumentException(validation);
|
||||
if (attempt.ProcessedCommandIds.Contains(request.CommandId, StringComparer.Ordinal)) return attempt;
|
||||
if (attempt.Revision != request.ExpectedRevision) throw new InvalidOperationException("Attempt revision is stale.");
|
||||
if (attempt.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned)
|
||||
throw new InvalidOperationException("The CCS attempt is closed.");
|
||||
if (attempt.State == CcsAttemptStates.FinalOrders && request.Commands.Any(value => value.Type is not ("order" or "cancel_order")))
|
||||
throw new InvalidOperationException("Only final orders may be changed now.");
|
||||
|
||||
var definition = await DefinitionAsync(attempt);
|
||||
var now = DateTime.UtcNow;
|
||||
if (request.Commands.Any(value => value.Type is "exam" or "interval_history"))
|
||||
{
|
||||
var selected = request.Commands.Where(item => item.Type == "exam").Select(item =>
|
||||
definition.Exams.FirstOrDefault(value => value.Code.Equals(item.Code, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentException($"Unknown examination component: {item.Code}.")).ToList();
|
||||
var includeHistory = request.Commands.Any(item => item.Type == "interval_history");
|
||||
var startingMinute = attempt.SimulatedMinute;
|
||||
foreach (var exam in selected)
|
||||
attempt.Actions.Add(Action("exam", exam.Code, startingMinute, now));
|
||||
if (includeHistory)
|
||||
attempt.Actions.Add(Action("interval_history", "interval_history", startingMinute, now));
|
||||
Advance(attempt, definition, selected.Sum(value => Math.Max(1, value.DurationMinutes)) + (includeHistory ? 1 : 0));
|
||||
if (includeHistory)
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
|
||||
});
|
||||
foreach (var exam in selected)
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var item in request.Commands)
|
||||
{
|
||||
if (item.Type == "order") await ApplyOrderAsync(attempt, definition, item.Code, now, false);
|
||||
else CancelOrder(attempt, item.Code, now);
|
||||
}
|
||||
ProcessDue(attempt);
|
||||
}
|
||||
|
||||
attempt.ProcessedCommandIds.Add(request.CommandId);
|
||||
if (attempt.ProcessedCommandIds.Count > 100)
|
||||
attempt.ProcessedCommandIds.RemoveRange(0, attempt.ProcessedCommandIds.Count - 100);
|
||||
attempt.Revision++;
|
||||
attempt.UpdatedUtc = now;
|
||||
await SaveAsync(attempt);
|
||||
return attempt;
|
||||
}
|
||||
|
||||
public async Task<CcsHistoryPage> HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 50);
|
||||
@@ -314,19 +389,49 @@ public sealed class CcsStore
|
||||
{
|
||||
var definition = await DefinitionAsync(attempt);
|
||||
var completed = attempt.State == CcsAttemptStates.Completed;
|
||||
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
|
||||
? []
|
||||
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
|
||||
var activeOrders = activeCatalog.Select(order =>
|
||||
{
|
||||
var orderedMinute = attempt.Actions.LastOrDefault(value =>
|
||||
value.Type == "order" && value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase))?.SimulatedMinute ?? 0;
|
||||
var pending = attempt.PendingResults.FirstOrDefault(value =>
|
||||
value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase));
|
||||
var reported = attempt.Chart.LastOrDefault(value =>
|
||||
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
|
||||
&& value.Category is not CcsChartCategories.Orders);
|
||||
return new CcsActiveOrder
|
||||
{
|
||||
Code = order.Code, Name = order.CanonicalName, Category = order.Category,
|
||||
Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute,
|
||||
ReportMinute = pending is not null ? null : reported?.SimulatedMinute,
|
||||
IsPending = pending is not null, HasResult = reported is not null
|
||||
};
|
||||
}).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList();
|
||||
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
|
||||
? attempt.VitalHistory
|
||||
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
|
||||
return new CcsAttemptResponse
|
||||
{
|
||||
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
|
||||
CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}",
|
||||
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
|
||||
SimulatedMinute = attempt.SimulatedMinute, Location = attempt.Location,
|
||||
DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
|
||||
SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc,
|
||||
OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location,
|
||||
DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
|
||||
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
|
||||
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
|
||||
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
|
||||
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
|
||||
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
|
||||
HistorySections = definition.InitialChart.HistorySections,
|
||||
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
|
||||
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute }).ToList(),
|
||||
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
|
||||
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
|
||||
{
|
||||
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title
|
||||
}).ToList(),
|
||||
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
|
||||
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
|
||||
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
|
||||
@@ -342,7 +447,7 @@ public sealed class CcsStore
|
||||
await SaveAsync(attempt);
|
||||
}
|
||||
|
||||
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
|
||||
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now, bool processImmediately = true)
|
||||
{
|
||||
code = code.Trim().ToLowerInvariant();
|
||||
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
|
||||
@@ -352,17 +457,30 @@ public sealed class CcsStore
|
||||
if (!attempt.ActiveOrderCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) attempt.ActiveOrderCodes.Add(code);
|
||||
var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
|
||||
var delay = response?.TurnaroundMinutes ?? catalog.DefaultTurnaroundMinutes;
|
||||
var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category);
|
||||
if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0))
|
||||
attempt.PendingResults.Add(new CcsPendingResult { OrderCode = code, DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches });
|
||||
attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}" });
|
||||
if (delay == 0) ProcessDue(attempt);
|
||||
attempt.PendingResults.Add(new CcsPendingResult
|
||||
{
|
||||
OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName,
|
||||
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches
|
||||
});
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName,
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}"
|
||||
});
|
||||
if (delay == 0 && processImmediately) ProcessDue(attempt);
|
||||
}
|
||||
|
||||
private static void ApplyExam(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
|
||||
{
|
||||
var exam = definition.Exams.FirstOrDefault(value => value.Code.Equals(code, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException("Unknown examination component.");
|
||||
attempt.Actions.Add(Action("exam", exam.Code, attempt.SimulatedMinute, now));
|
||||
attempt.Chart.Add(new CcsChartEntry { Category = "exam", SimulatedMinute = attempt.SimulatedMinute, Text = $"{exam.Name}: {exam.Finding}" });
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
|
||||
});
|
||||
Advance(attempt, definition, Math.Max(1, exam.DurationMinutes));
|
||||
}
|
||||
|
||||
@@ -371,7 +489,11 @@ public sealed class CcsStore
|
||||
attempt.Actions.Add(Action("cancel_order", code, attempt.SimulatedMinute, now));
|
||||
attempt.ActiveOrderCodes.RemoveAll(value => value.Equals(code, StringComparison.OrdinalIgnoreCase));
|
||||
attempt.PendingResults.RemoveAll(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
|
||||
attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {code}" });
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Orders, Code = code, Title = Humanize(code),
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {Humanize(code)}"
|
||||
});
|
||||
}
|
||||
|
||||
private static void ChangeLocation(CcsAttempt attempt, CcsCaseRevision definition, string location, DateTime now)
|
||||
@@ -379,7 +501,11 @@ public sealed class CcsStore
|
||||
if (!definition.AllowedLocations.Contains(location, StringComparer.OrdinalIgnoreCase)) throw new ArgumentException("Unsupported location.");
|
||||
attempt.Location = location;
|
||||
attempt.Actions.Add(Action("location", location, attempt.SimulatedMinute, now));
|
||||
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {location}." });
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = "location", Title = "Location change",
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {Humanize(location)}."
|
||||
});
|
||||
}
|
||||
|
||||
private static void Advance(CcsAttempt attempt, CcsCaseRevision definition, int minutes)
|
||||
@@ -403,13 +529,20 @@ public sealed class CcsStore
|
||||
private static void ProcessDue(CcsAttempt attempt)
|
||||
{
|
||||
var due = attempt.PendingResults.Where(value => value.DueMinute <= attempt.SimulatedMinute).ToList();
|
||||
var vitalsChanged = false;
|
||||
foreach (var result in due)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(result.Result))
|
||||
attempt.Chart.Add(new CcsChartEntry { Category = "results", SimulatedMinute = attempt.SimulatedMinute, Text = result.Result });
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = string.IsNullOrWhiteSpace(result.Category) ? CcsChartCategories.OtherTest : result.Category,
|
||||
Code = result.OrderCode, Title = result.Title, SimulatedMinute = attempt.SimulatedMinute, Text = result.Result
|
||||
});
|
||||
foreach (var patch in result.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
|
||||
vitalsChanged |= result.VitalPatches.Count > 0;
|
||||
attempt.PendingResults.Remove(result);
|
||||
}
|
||||
if (vitalsChanged) SnapshotVitals(attempt);
|
||||
}
|
||||
|
||||
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
|
||||
@@ -419,13 +552,20 @@ public sealed class CcsStore
|
||||
&& value.TriggerMinute <= attempt.SimulatedMinute
|
||||
&& value.RequiresOrderCodes.All(activeOrders.Contains)
|
||||
&& (value.RequiresAnyOrderCodes.Count == 0 || value.RequiresAnyOrderCodes.Any(activeOrders.Contains))).ToList();
|
||||
var vitalsChanged = false;
|
||||
foreach (var item in events)
|
||||
{
|
||||
attempt.ProcessedEventIds.Add(item.Id);
|
||||
foreach (var patch in item.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
|
||||
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = item.Message });
|
||||
vitalsChanged |= item.VitalPatches.Count > 0;
|
||||
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = item.Id, Title = "Clinical update",
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = item.Message
|
||||
});
|
||||
if (item.EndCase) EnterFinalOrders(attempt);
|
||||
}
|
||||
if (vitalsChanged) SnapshotVitals(attempt);
|
||||
return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase);
|
||||
}
|
||||
|
||||
@@ -433,9 +573,21 @@ public sealed class CcsStore
|
||||
{
|
||||
if (attempt.State != CcsAttemptStates.Active) return;
|
||||
attempt.State = CcsAttemptStates.FinalOrders;
|
||||
var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow;
|
||||
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2));
|
||||
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "Case-end instructions: finalize patient care orders." });
|
||||
if (attempt.HardDeadlineUtc is not null)
|
||||
{
|
||||
attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc);
|
||||
}
|
||||
else
|
||||
{
|
||||
var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow;
|
||||
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(
|
||||
remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2));
|
||||
}
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions",
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders."
|
||||
});
|
||||
}
|
||||
|
||||
private async Task CompleteAsync(CcsAttempt attempt)
|
||||
@@ -489,12 +641,44 @@ public sealed class CcsStore
|
||||
};
|
||||
}
|
||||
|
||||
public static int ActivePhaseMinutes(int totalMinutes) => Math.Max(1, totalMinutes - 2);
|
||||
|
||||
public static DateTime FinalOrdersDeadline(DateTime now, DateTime? hardDeadline)
|
||||
{
|
||||
var proposed = now.AddMinutes(2);
|
||||
return hardDeadline is null || proposed <= hardDeadline ? proposed : hardDeadline.Value;
|
||||
}
|
||||
|
||||
public async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
|
||||
await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync()
|
||||
?? throw new InvalidOperationException("The case revision is unavailable.");
|
||||
|
||||
private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt);
|
||||
private static CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now };
|
||||
private static void SnapshotVitals(CcsAttempt attempt)
|
||||
{
|
||||
if (attempt.VitalHistory.Count > 0
|
||||
&& attempt.VitalHistory[^1].SimulatedMinute == attempt.SimulatedMinute
|
||||
&& attempt.VitalHistory[^1].Values.OrderBy(value => value.Key).SequenceEqual(attempt.CurrentVitals.OrderBy(value => value.Key)))
|
||||
return;
|
||||
attempt.VitalHistory.Add(new CcsVitalSnapshot
|
||||
{
|
||||
SimulatedMinute = attempt.SimulatedMinute,
|
||||
Values = new Dictionary<string, string>(attempt.CurrentVitals)
|
||||
});
|
||||
}
|
||||
private static string ResultCategory(string? responseSection, string catalogSection, string catalogCategory)
|
||||
{
|
||||
var explicitSection = string.IsNullOrWhiteSpace(responseSection) ? catalogSection : responseSection;
|
||||
if (!string.IsNullOrWhiteSpace(explicitSection)) return explicitSection;
|
||||
return catalogCategory.ToLowerInvariant() switch
|
||||
{
|
||||
"laboratory" => CcsChartCategories.Lab,
|
||||
"imaging" => CcsChartCategories.Imaging,
|
||||
"medication" or "procedure" or "monitoring" => CcsChartCategories.Treatment,
|
||||
_ => CcsChartCategories.OtherTest
|
||||
};
|
||||
}
|
||||
private static string Humanize(string value) => string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries).Select(word => char.ToUpperInvariant(word[0]) + word[1..]));
|
||||
private static string DescribeExpected(CcsScoringCriterion criterion, List<string> targets)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user