Schedule graphical CCS procedures
This commit is contained in:
@@ -128,6 +128,11 @@ public sealed class CcsCaseTests
|
|||||||
Assert.Contains(request.Orders, value => value.Code == "bismuth_quadruple");
|
Assert.Contains(request.Orders, value => value.Code == "bismuth_quadruple");
|
||||||
Assert.Contains(request.Cases.Single().OrderResponses,
|
Assert.Contains(request.Cases.Single().OrderResponses,
|
||||||
value => value.OrderCode == "upper_endoscopy" && value.Result.Contains("duodenal", StringComparison.OrdinalIgnoreCase));
|
value => value.OrderCode == "upper_endoscopy" && value.Result.Contains("duodenal", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var endoscopy = request.Cases.Single().OrderResponses.Single(value => value.OrderCode == "upper_endoscopy");
|
||||||
|
Assert.Equal("endoscopy_suite", endoscopy.PerformanceLocation);
|
||||||
|
Assert.True(endoscopy.RequiresPatientVisit);
|
||||||
|
Assert.All(request.Cases.Single().OrderResponses.Where(value => value.RequiresPatientVisit),
|
||||||
|
value => Assert.False(string.IsNullOrWhiteSpace(value.PerformanceLocation)));
|
||||||
Assert.Contains(request.Cases.Single().ScoringCriteria,
|
Assert.Contains(request.Cases.Single().ScoringCriteria,
|
||||||
value => value.TargetCode == "ibuprofen" && value.Points < 0);
|
value => value.TargetCode == "ibuprofen" && value.Points < 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ public class CcsCaseOrderDefinition
|
|||||||
{
|
{
|
||||||
public string OrderCode { get; set; } = "";
|
public string OrderCode { get; set; } = "";
|
||||||
public int? TurnaroundMinutes { get; set; }
|
public int? TurnaroundMinutes { get; set; }
|
||||||
|
public string PerformanceLocation { get; set; } = "";
|
||||||
|
public bool RequiresPatientVisit { get; set; }
|
||||||
public string Result { get; set; } = "";
|
public string Result { get; set; } = "";
|
||||||
public string ResultSection { get; set; } = "";
|
public string ResultSection { get; set; } = "";
|
||||||
public Dictionary<string, string> VitalPatches { get; set; } = [];
|
public Dictionary<string, string> VitalPatches { get; set; } = [];
|
||||||
@@ -243,6 +245,8 @@ public class CcsPendingResult
|
|||||||
public string Category { get; set; } = "";
|
public string Category { get; set; } = "";
|
||||||
public string Title { get; set; } = "";
|
public string Title { get; set; } = "";
|
||||||
public int DueMinute { get; set; }
|
public int DueMinute { get; set; }
|
||||||
|
public string PerformanceLocation { get; set; } = "";
|
||||||
|
public bool RequiresPatientVisit { get; set; }
|
||||||
public string Result { get; set; } = "";
|
public string Result { get; set; } = "";
|
||||||
public Dictionary<string, string> VitalPatches { get; set; } = [];
|
public Dictionary<string, string> VitalPatches { get; set; } = [];
|
||||||
}
|
}
|
||||||
@@ -271,10 +275,22 @@ public class CcsActiveOrder
|
|||||||
public string Frequency { get; set; } = "";
|
public string Frequency { get; set; } = "";
|
||||||
public int OrderedMinute { get; set; }
|
public int OrderedMinute { get; set; }
|
||||||
public int? ReportMinute { get; set; }
|
public int? ReportMinute { get; set; }
|
||||||
|
public int? DueMinute { get; set; }
|
||||||
|
public string PerformanceLocation { get; set; } = "";
|
||||||
|
public bool RequiresPatientVisit { get; set; }
|
||||||
|
public string Status { get; set; } = "active";
|
||||||
public bool IsPending { get; set; }
|
public bool IsPending { get; set; }
|
||||||
public bool HasResult { get; set; }
|
public bool HasResult { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class CcsOrderOption
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = "";
|
||||||
|
public int TurnaroundMinutes { get; set; }
|
||||||
|
public string PerformanceLocation { get; set; } = "";
|
||||||
|
public bool RequiresPatientVisit { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class CcsCaseScore
|
public class CcsCaseScore
|
||||||
{
|
{
|
||||||
public double Percent { get; set; }
|
public double Percent { get; set; }
|
||||||
@@ -385,6 +401,7 @@ public class CcsAttemptResponse
|
|||||||
public List<CcsChartEntry> Chart { get; set; } = [];
|
public List<CcsChartEntry> Chart { get; set; } = [];
|
||||||
public List<string> ActiveOrderCodes { get; set; } = [];
|
public List<string> ActiveOrderCodes { get; set; } = [];
|
||||||
public List<CcsActiveOrder> ActiveOrders { get; set; } = [];
|
public List<CcsActiveOrder> ActiveOrders { get; set; } = [];
|
||||||
|
public List<CcsOrderOption> OrderOptions { get; set; } = [];
|
||||||
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
|
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
|
||||||
public List<CcsPendingResult> PendingResults { get; set; } = [];
|
public List<CcsPendingResult> PendingResults { get; set; } = [];
|
||||||
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
|
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ public static class CcsCaseValidator
|
|||||||
foreach (var response in item.OrderResponses)
|
foreach (var response in item.OrderResponses)
|
||||||
{
|
{
|
||||||
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
|
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
|
||||||
|
if (response.TurnaroundMinutes < 0) errors.Add(prefix + $"order response {response.OrderCode} turnaroundMinutes cannot be negative");
|
||||||
|
if (response.RequiresPatientVisit && string.IsNullOrWhiteSpace(response.PerformanceLocation))
|
||||||
|
errors.Add(prefix + $"order response {response.OrderCode} requires a performanceLocation");
|
||||||
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
|
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
|
||||||
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
|
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,6 +411,23 @@ public sealed class CcsStore
|
|||||||
{
|
{
|
||||||
var definition = await DefinitionAsync(attempt);
|
var definition = await DefinitionAsync(attempt);
|
||||||
var completed = attempt.State == CcsAttemptStates.Completed;
|
var completed = attempt.State == CcsAttemptStates.Completed;
|
||||||
|
var optionCodes = definition.OrderResponses.Select(response => response.OrderCode).ToList();
|
||||||
|
List<CcsOrderDefinition> optionCatalog = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||||
|
? await _orders.Find(value => optionCodes.Contains(value.Code)).ToListAsync()
|
||||||
|
: [];
|
||||||
|
var orderOptions = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||||
|
? definition.OrderResponses.Select(response =>
|
||||||
|
{
|
||||||
|
var catalog = optionCatalog.FirstOrDefault(value => value.Code.Equals(response.OrderCode, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return new CcsOrderOption
|
||||||
|
{
|
||||||
|
Code = response.OrderCode,
|
||||||
|
TurnaroundMinutes = response.TurnaroundMinutes ?? catalog?.DefaultTurnaroundMinutes ?? 0,
|
||||||
|
PerformanceLocation = PerformanceLocation(response, catalog, attempt.Location),
|
||||||
|
RequiresPatientVisit = response.RequiresPatientVisit
|
||||||
|
};
|
||||||
|
}).ToList()
|
||||||
|
: [];
|
||||||
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
|
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
|
||||||
? []
|
? []
|
||||||
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
|
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
|
||||||
@@ -423,11 +440,19 @@ public sealed class CcsStore
|
|||||||
var reported = attempt.Chart.LastOrDefault(value =>
|
var reported = attempt.Chart.LastOrDefault(value =>
|
||||||
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
|
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
|
||||||
&& value.Category is not CcsChartCategories.Orders);
|
&& value.Category is not CcsChartCategories.Orders);
|
||||||
|
var response = definition.OrderResponses.FirstOrDefault(value =>
|
||||||
|
value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase));
|
||||||
|
var performanceLocation = pending?.PerformanceLocation ?? PerformanceLocation(response, order, attempt.Location);
|
||||||
|
var requiresPatientVisit = pending?.RequiresPatientVisit ?? response?.RequiresPatientVisit == true;
|
||||||
return new CcsActiveOrder
|
return new CcsActiveOrder
|
||||||
{
|
{
|
||||||
Code = order.Code, Name = order.CanonicalName, Category = order.Category,
|
Code = order.Code, Name = order.CanonicalName, Category = order.Category,
|
||||||
Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute,
|
Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute,
|
||||||
ReportMinute = pending is not null ? null : reported?.SimulatedMinute,
|
ReportMinute = pending is not null ? null : reported?.SimulatedMinute,
|
||||||
|
DueMinute = pending?.DueMinute, PerformanceLocation = performanceLocation,
|
||||||
|
RequiresPatientVisit = requiresPatientVisit,
|
||||||
|
Status = pending is not null ? (requiresPatientVisit ? "scheduled" : "pending")
|
||||||
|
: reported is not null ? "completed" : "active",
|
||||||
IsPending = pending is not null, HasResult = reported is not null
|
IsPending = pending is not null, HasResult = reported is not null
|
||||||
};
|
};
|
||||||
}).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList();
|
}).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList();
|
||||||
@@ -469,10 +494,11 @@ public sealed class CcsStore
|
|||||||
? [] : definition.InitialChart.HistorySections,
|
? [] : definition.InitialChart.HistorySections,
|
||||||
MedicalHistory = MedicalHistory(definition),
|
MedicalHistory = MedicalHistory(definition),
|
||||||
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
|
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
|
||||||
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
|
ActiveOrders = activeOrders, OrderOptions = orderOptions, VitalHistory = vitalHistory,
|
||||||
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
|
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
|
||||||
{
|
{
|
||||||
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title
|
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title,
|
||||||
|
PerformanceLocation = value.PerformanceLocation, RequiresPatientVisit = value.RequiresPatientVisit
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
|
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
|
||||||
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
|
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
|
||||||
@@ -525,23 +551,31 @@ public sealed class CcsStore
|
|||||||
{
|
{
|
||||||
code = code.Trim().ToLowerInvariant();
|
code = code.Trim().ToLowerInvariant();
|
||||||
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
|
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
|
||||||
if (catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase))
|
var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && response is null)
|
||||||
|
throw new InvalidOperationException("That order is not configured for this case.");
|
||||||
|
if (response is null && catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase))
|
||||||
throw new InvalidOperationException("That order is unavailable at the current location.");
|
throw new InvalidOperationException("That order is unavailable at the current location.");
|
||||||
attempt.Actions.Add(Action("order", code, attempt.SimulatedMinute, now));
|
attempt.Actions.Add(Action("order", code, attempt.SimulatedMinute, now));
|
||||||
if (!attempt.ActiveOrderCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) attempt.ActiveOrderCodes.Add(code);
|
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 delay = response?.TurnaroundMinutes ?? catalog.DefaultTurnaroundMinutes;
|
||||||
|
var performanceLocation = PerformanceLocation(response, catalog, attempt.Location);
|
||||||
|
var requiresPatientVisit = response?.RequiresPatientVisit == true;
|
||||||
var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category);
|
var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category);
|
||||||
if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0))
|
if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0))
|
||||||
attempt.PendingResults.Add(new CcsPendingResult
|
attempt.PendingResults.Add(new CcsPendingResult
|
||||||
{
|
{
|
||||||
OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName,
|
OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName,
|
||||||
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches
|
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches,
|
||||||
|
PerformanceLocation = performanceLocation, RequiresPatientVisit = requiresPatientVisit
|
||||||
});
|
});
|
||||||
attempt.Chart.Add(new CcsChartEntry
|
attempt.Chart.Add(new CcsChartEntry
|
||||||
{
|
{
|
||||||
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName,
|
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName,
|
||||||
SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}"
|
SimulatedMinute = attempt.SimulatedMinute,
|
||||||
|
Text = requiresPatientVisit
|
||||||
|
? $"Scheduled: {catalog.CanonicalName} at {Humanize(performanceLocation)}; expected in {delay} minutes."
|
||||||
|
: $"Ordered: {catalog.CanonicalName}"
|
||||||
});
|
});
|
||||||
if (delay == 0 && processImmediately) ProcessDue(attempt);
|
if (delay == 0 && processImmediately) ProcessDue(attempt);
|
||||||
}
|
}
|
||||||
@@ -606,6 +640,13 @@ public sealed class CcsStore
|
|||||||
var vitalsChanged = false;
|
var vitalsChanged = false;
|
||||||
foreach (var result in due)
|
foreach (var result in due)
|
||||||
{
|
{
|
||||||
|
if (result.RequiresPatientVisit)
|
||||||
|
attempt.Chart.Add(new CcsChartEntry
|
||||||
|
{
|
||||||
|
Category = CcsChartCategories.Progress, Code = $"{result.OrderCode}_completed", Title = "Patient returned",
|
||||||
|
SimulatedMinute = attempt.SimulatedMinute,
|
||||||
|
Text = $"The patient completed {result.Title} at {Humanize(result.PerformanceLocation)} and returned."
|
||||||
|
});
|
||||||
if (!string.IsNullOrWhiteSpace(result.Result))
|
if (!string.IsNullOrWhiteSpace(result.Result))
|
||||||
attempt.Chart.Add(new CcsChartEntry
|
attempt.Chart.Add(new CcsChartEntry
|
||||||
{
|
{
|
||||||
@@ -619,6 +660,13 @@ public sealed class CcsStore
|
|||||||
if (vitalsChanged) SnapshotVitals(attempt);
|
if (vitalsChanged) SnapshotVitals(attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string PerformanceLocation(CcsCaseOrderDefinition? response, CcsOrderDefinition? catalog, string currentLocation)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(response?.PerformanceLocation)) return response.PerformanceLocation;
|
||||||
|
if (catalog?.AvailableLocations.Contains(currentLocation, StringComparer.OrdinalIgnoreCase) == true) return currentLocation;
|
||||||
|
return catalog?.AvailableLocations.FirstOrDefault() ?? currentLocation;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
|
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
|
||||||
{
|
{
|
||||||
var activeOrders = attempt.Actions.Where(value => value.Type == "order").Select(value => value.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var activeOrders = attempt.Actions.Where(value => value.Type == "order").Select(value => value.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|||||||
@@ -138,16 +138,16 @@
|
|||||||
{ "code": "neuro_psych", "name": "Neuro / Psych", "durationMinutes": 1, "finding": "He is alert and fully oriented with normal speech, strength, sensation, coordination, gait, mood, and affect." }
|
{ "code": "neuro_psych", "name": "Neuro / Psych", "durationMinutes": 1, "finding": "He is alert and fully oriented with normal speech, strength, sensation, coordination, gait, mood, and affect." }
|
||||||
],
|
],
|
||||||
"orderResponses": [
|
"orderResponses": [
|
||||||
{ "orderCode": "cbc", "turnaroundMinutes": 20, "result": "Hemoglobin is 10.4 g/dL, hematocrit 32%, MCV 74 fL, leukocyte count 7,100/mm3, and platelet count 312,000/mm3.", "resultSection": "lab" },
|
{ "orderCode": "cbc", "turnaroundMinutes": 20, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "Hemoglobin is 10.4 g/dL, hematocrit 32%, MCV 74 fL, leukocyte count 7,100/mm3, and platelet count 312,000/mm3.", "resultSection": "lab" },
|
||||||
{ "orderCode": "cmp", "turnaroundMinutes": 25, "result": "BUN is 27 mg/dL and creatinine 1.0 mg/dL. Electrolytes, glucose, aminotransferases, bilirubin, and albumin are within reference ranges.", "resultSection": "lab" },
|
{ "orderCode": "cmp", "turnaroundMinutes": 25, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "BUN is 27 mg/dL and creatinine 1.0 mg/dL. Electrolytes, glucose, aminotransferases, bilirubin, and albumin are within reference ranges.", "resultSection": "lab" },
|
||||||
{ "orderCode": "iron_studies", "turnaroundMinutes": 30, "result": "Ferritin is 10 ng/mL, serum iron 31 mcg/dL, and transferrin saturation 9%, supporting iron deficiency.", "resultSection": "lab" },
|
{ "orderCode": "iron_studies", "turnaroundMinutes": 30, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "Ferritin is 10 ng/mL, serum iron 31 mcg/dL, and transferrin saturation 9%, supporting iron deficiency.", "resultSection": "lab" },
|
||||||
{ "orderCode": "h_pylori_breath", "turnaroundMinutes": 45, "result": "Urea breath test is positive for Helicobacter pylori.", "resultSection": "other_test" },
|
{ "orderCode": "h_pylori_breath", "turnaroundMinutes": 45, "performanceLocation": "office_diagnostic_area", "requiresPatientVisit": true, "result": "Urea breath test is positive for Helicobacter pylori.", "resultSection": "other_test" },
|
||||||
{ "orderCode": "upper_endoscopy", "turnaroundMinutes": 60, "result": "Upper endoscopy shows an 8 mm clean-based ulcer in the duodenal bulb without active bleeding. Gastric biopsy is positive for H. pylori and shows no dysplasia or malignancy.", "resultSection": "other_test" },
|
{ "orderCode": "upper_endoscopy", "turnaroundMinutes": 60, "performanceLocation": "endoscopy_suite", "requiresPatientVisit": true, "result": "Upper endoscopy shows an 8 mm clean-based ulcer in the duodenal bulb without active bleeding. Gastric biopsy is positive for H. pylori and shows no dysplasia or malignancy.", "resultSection": "other_test" },
|
||||||
{ "orderCode": "omeprazole", "turnaroundMinutes": 0, "result": "Proton-pump inhibitor therapy is started.", "resultSection": "treatment" },
|
{ "orderCode": "omeprazole", "turnaroundMinutes": 0, "result": "Proton-pump inhibitor therapy is started.", "resultSection": "treatment" },
|
||||||
{ "orderCode": "bismuth_quadruple", "turnaroundMinutes": 0, "result": "A 14-day optimized bismuth quadruple regimen is prescribed.", "resultSection": "treatment" },
|
{ "orderCode": "bismuth_quadruple", "turnaroundMinutes": 0, "result": "A 14-day optimized bismuth quadruple regimen is prescribed.", "resultSection": "treatment" },
|
||||||
{ "orderCode": "h_pylori_test_of_cure", "turnaroundMinutes": 0, "result": "Test-of-cure follow-up is scheduled for at least four weeks after therapy, with PPI held for two weeks before testing.", "resultSection": "treatment" },
|
{ "orderCode": "h_pylori_test_of_cure", "turnaroundMinutes": 0, "result": "Test-of-cure follow-up is scheduled for at least four weeks after therapy, with PPI held for two weeks before testing.", "resultSection": "treatment" },
|
||||||
{ "orderCode": "gastro_follow_up", "turnaroundMinutes": 0, "result": "Gastroenterology follow-up is arranged to review healing, anemia, and eradication testing.", "resultSection": "treatment" },
|
{ "orderCode": "gastro_follow_up", "turnaroundMinutes": 0, "result": "Gastroenterology follow-up is arranged to review healing, anemia, and eradication testing.", "resultSection": "treatment" },
|
||||||
{ "orderCode": "abdominal_ct", "turnaroundMinutes": 60, "result": "CT of the abdomen and pelvis shows no acute intra-abdominal abnormality.", "resultSection": "imaging" },
|
{ "orderCode": "abdominal_ct", "turnaroundMinutes": 60, "performanceLocation": "imaging_center", "requiresPatientVisit": true, "result": "CT of the abdomen and pelvis shows no acute intra-abdominal abnormality.", "resultSection": "imaging" },
|
||||||
{ "orderCode": "clarithromycin_triple", "turnaroundMinutes": 0, "result": "Empiric clarithromycin-based therapy is prescribed without susceptibility confirmation.", "resultSection": "treatment" },
|
{ "orderCode": "clarithromycin_triple", "turnaroundMinutes": 0, "result": "Empiric clarithromycin-based therapy is prescribed without susceptibility confirmation.", "resultSection": "treatment" },
|
||||||
{ "orderCode": "ibuprofen", "turnaroundMinutes": 0, "result": "Ibuprofen is prescribed despite suspected peptic ulcer disease and possible gastrointestinal bleeding.", "resultSection": "treatment" }
|
{ "orderCode": "ibuprofen", "turnaroundMinutes": 0, "result": "Ibuprofen is prescribed despite suspected peptic ulcer disease and possible gastrointestinal bleeding.", "resultSection": "treatment" }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -88,10 +88,16 @@ var orders_status: Label
|
|||||||
var order_search_timer: Timer
|
var order_search_timer: Timer
|
||||||
var staged_order_codes: Array[String] = []
|
var staged_order_codes: Array[String] = []
|
||||||
var order_names := {}
|
var order_names := {}
|
||||||
|
var order_options := {}
|
||||||
var final_orders_mode := false
|
var final_orders_mode := false
|
||||||
var active_order_codes: Array = []
|
var active_order_codes: Array = []
|
||||||
var current_order_location := ""
|
var current_order_location := ""
|
||||||
|
var current_order_minute := 0
|
||||||
var known_order_result_count := -1
|
var known_order_result_count := -1
|
||||||
|
var known_visit_completions: Array[String] = []
|
||||||
|
var visit_completion_tracking_ready := false
|
||||||
|
var patient_update_panel: PanelContainer
|
||||||
|
var patient_update_text: Label
|
||||||
var experience_mode := "management"
|
var experience_mode := "management"
|
||||||
var pending_choices: Array = []
|
var pending_choices: Array = []
|
||||||
var available_choices: Array = []
|
var available_choices: Array = []
|
||||||
@@ -132,6 +138,7 @@ func _ready() -> void:
|
|||||||
_style_desktop_shortcut(orders_launcher)
|
_style_desktop_shortcut(orders_launcher)
|
||||||
_build_medical_history_tabs()
|
_build_medical_history_tabs()
|
||||||
_build_orders_window()
|
_build_orders_window()
|
||||||
|
_build_patient_update()
|
||||||
_resize_computer_windows()
|
_resize_computer_windows()
|
||||||
_update_clock()
|
_update_clock()
|
||||||
_schedule_patient_blink()
|
_schedule_patient_blink()
|
||||||
@@ -315,6 +322,8 @@ func complete_response_transition() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _input(event: InputEvent) -> void:
|
func _input(event: InputEvent) -> void:
|
||||||
|
if patient_update_panel != null and patient_update_panel.visible:
|
||||||
|
return
|
||||||
if computer_overlay.visible or (physical_exam_panel != null and physical_exam_panel.visible):
|
if computer_overlay.visible or (physical_exam_panel != null and physical_exam_panel.visible):
|
||||||
return
|
return
|
||||||
if _event_targets_computer_launcher(event):
|
if _event_targets_computer_launcher(event):
|
||||||
@@ -370,6 +379,9 @@ func is_computer_open() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
func close_transient_overlay() -> bool:
|
func close_transient_overlay() -> bool:
|
||||||
|
if patient_update_panel != null and patient_update_panel.visible:
|
||||||
|
patient_update_panel.visible = false
|
||||||
|
return true
|
||||||
if physical_exam_panel != null and physical_exam_panel.visible:
|
if physical_exam_panel != null and physical_exam_panel.visible:
|
||||||
if physical_exam_result_button.visible:
|
if physical_exam_result_button.visible:
|
||||||
_close_exam_result()
|
_close_exam_result()
|
||||||
@@ -591,22 +603,29 @@ func _queue_live_order_search(query: String) -> void:
|
|||||||
|
|
||||||
func render_order_search(rows: Array) -> void:
|
func render_order_search(rows: Array) -> void:
|
||||||
_clear_dynamic_children(orders_search_results)
|
_clear_dynamic_children(orders_search_results)
|
||||||
var available_count := 0
|
var result_count := 0
|
||||||
for raw_order in rows:
|
for raw_order in rows:
|
||||||
var order: Dictionary = raw_order
|
var order: Dictionary = raw_order
|
||||||
var locations: Array = order.get("availableLocations", [])
|
result_count += 1
|
||||||
if not locations.is_empty() and current_order_location not in locations:
|
|
||||||
continue
|
|
||||||
available_count += 1
|
|
||||||
var code := str(order.get("code", ""))
|
var code := str(order.get("code", ""))
|
||||||
order_names[code] = str(order.get("canonicalName", code.capitalize()))
|
order_names[code] = str(order.get("canonicalName", code.capitalize()))
|
||||||
|
var option: Dictionary = order_options.get(code, {})
|
||||||
|
var configured := not option.is_empty()
|
||||||
var button := Button.new()
|
var button := Button.new()
|
||||||
button.text = "%s · %s" % [order_names[code], str(order.get("category", "")).capitalize()]
|
var detail := str(order.get("category", "")).capitalize()
|
||||||
|
if configured:
|
||||||
|
var location := _humanize_order_location(str(option.get("performanceLocation", current_order_location)))
|
||||||
|
var delay := int(option.get("turnaroundMinutes", 0))
|
||||||
|
detail += " - %s%s" % [location, " - %d min" % delay if delay > 0 else ""]
|
||||||
|
else:
|
||||||
|
detail += " - Unavailable in this case"
|
||||||
|
button.text = "%s\n%s" % [order_names[code], detail]
|
||||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
button.disabled = code in staged_order_codes or code in active_order_codes
|
button.disabled = not configured or code in staged_order_codes or code in active_order_codes
|
||||||
|
button.tooltip_text = "This order is not configured for this case." if not configured else ""
|
||||||
button.pressed.connect(_stage_order.bind(code))
|
button.pressed.connect(_stage_order.bind(code))
|
||||||
orders_search_results.add_child(button)
|
orders_search_results.add_child(button)
|
||||||
orders_status.text = "" if available_count > 0 else "No matching orders are available at this location."
|
orders_status.text = "" if result_count > 0 else "No matching orders found."
|
||||||
|
|
||||||
|
|
||||||
func _stage_order(code: String) -> void:
|
func _stage_order(code: String) -> void:
|
||||||
@@ -622,7 +641,9 @@ func _render_staged_orders() -> void:
|
|||||||
var row := HBoxContainer.new()
|
var row := HBoxContainer.new()
|
||||||
orders_staged.add_child(row)
|
orders_staged.add_child(row)
|
||||||
var label := Label.new()
|
var label := Label.new()
|
||||||
label.text = str(order_names.get(code, code.capitalize()))
|
var option: Dictionary = order_options.get(code, {})
|
||||||
|
var location := _humanize_order_location(str(option.get("performanceLocation", current_order_location)))
|
||||||
|
label.text = "%s - %s" % [str(order_names.get(code, code.capitalize())), location]
|
||||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
row.add_child(label)
|
row.add_child(label)
|
||||||
var remove := Button.new()
|
var remove := Button.new()
|
||||||
@@ -647,12 +668,18 @@ func configure_orders(attempt: Dictionary) -> void:
|
|||||||
if orders_window == null:
|
if orders_window == null:
|
||||||
return
|
return
|
||||||
current_order_location = str(attempt.get("location", ""))
|
current_order_location = str(attempt.get("location", ""))
|
||||||
|
current_order_minute = int(attempt.get("simulatedMinute", 0))
|
||||||
|
order_options.clear()
|
||||||
|
for raw_option in attempt.get("orderOptions", []):
|
||||||
|
var option: Dictionary = raw_option
|
||||||
|
order_options[str(option.get("code", ""))] = option
|
||||||
active_order_codes = attempt.get("activeOrderCodes", []).duplicate()
|
active_order_codes = attempt.get("activeOrderCodes", []).duplicate()
|
||||||
for code in attempt.get("activeOrderCodes", []):
|
for code in attempt.get("activeOrderCodes", []):
|
||||||
staged_order_codes.erase(str(code))
|
staged_order_codes.erase(str(code))
|
||||||
_render_staged_orders()
|
_render_staged_orders()
|
||||||
_render_active_orders(attempt.get("activeOrders", []))
|
_render_active_orders(attempt.get("activeOrders", []))
|
||||||
_render_order_results(attempt.get("chart", []))
|
_render_order_results(attempt.get("chart", []))
|
||||||
|
_track_patient_visit_completions(attempt.get("chart", []))
|
||||||
var result_count := _order_result_count(attempt.get("chart", []))
|
var result_count := _order_result_count(attempt.get("chart", []))
|
||||||
if known_order_result_count >= 0 and result_count > known_order_result_count and not orders_window.visible:
|
if known_order_result_count >= 0 and result_count > known_order_result_count and not orders_window.visible:
|
||||||
orders_launcher.get_node("Label").text = "Orders •"
|
orders_launcher.get_node("Label").text = "Orders •"
|
||||||
@@ -676,13 +703,13 @@ func show_order_action_complete(action: String) -> void:
|
|||||||
match action:
|
match action:
|
||||||
"orders":
|
"orders":
|
||||||
orders_tabs.current_tab = 1
|
orders_tabs.current_tab = 1
|
||||||
orders_status.text = "Orders confirmed. Use Obtain Results for pending tests."
|
orders_status.text = "Orders confirmed and automatically scheduled. Use Obtain Results to advance time."
|
||||||
"cancel_order":
|
"cancel_order":
|
||||||
orders_tabs.current_tab = 1
|
orders_tabs.current_tab = 1
|
||||||
orders_status.text = "Order cancelled."
|
orders_status.text = "Order cancelled."
|
||||||
"advance":
|
"advance":
|
||||||
orders_tabs.current_tab = 2
|
orders_tabs.current_tab = 2
|
||||||
orders_status.text = "The chart has been updated with all results now available."
|
orders_status.text = "Simulated time advanced. Newly available results are shown here."
|
||||||
|
|
||||||
|
|
||||||
func _render_active_orders(rows: Array) -> void:
|
func _render_active_orders(rows: Array) -> void:
|
||||||
@@ -697,7 +724,12 @@ func _render_active_orders(rows: Array) -> void:
|
|||||||
var row := HBoxContainer.new()
|
var row := HBoxContainer.new()
|
||||||
orders_active.add_child(row)
|
orders_active.add_child(row)
|
||||||
var label := Label.new()
|
var label := Label.new()
|
||||||
label.text = "%s — %s, %s" % [str(order.get("name", "Order")), str(order.get("route", "")), str(order.get("frequency", ""))]
|
var status := str(order.get("status", "active")).capitalize()
|
||||||
|
var location := _humanize_order_location(str(order.get("performanceLocation", current_order_location)))
|
||||||
|
var due_text := ""
|
||||||
|
if order.get("dueMinute", null) != null:
|
||||||
|
due_text = " - due in %d min" % maxi(0, int(order.get("dueMinute", 0)) - current_order_minute)
|
||||||
|
label.text = "%s - %s at %s%s" % [str(order.get("name", "Order")), status, location, due_text]
|
||||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
row.add_child(label)
|
row.add_child(label)
|
||||||
var cancel := Button.new()
|
var cancel := Button.new()
|
||||||
@@ -723,6 +755,66 @@ func _order_result_count(chart: Array) -> int:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
func _humanize_order_location(value: String) -> String:
|
||||||
|
if value.is_empty():
|
||||||
|
return "Current location"
|
||||||
|
return value.replace("_", " ").capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_patient_update() -> void:
|
||||||
|
patient_update_panel = PanelContainer.new()
|
||||||
|
patient_update_panel.name = "PatientProcedureUpdate"
|
||||||
|
patient_update_panel.visible = false
|
||||||
|
patient_update_panel.z_index = 100
|
||||||
|
patient_update_panel.position = Vector2(400, 300)
|
||||||
|
patient_update_panel.custom_minimum_size = Vector2(800, 0)
|
||||||
|
add_child(patient_update_panel)
|
||||||
|
var margin := MarginContainer.new()
|
||||||
|
margin.add_theme_constant_override("margin_left", 24)
|
||||||
|
margin.add_theme_constant_override("margin_right", 24)
|
||||||
|
margin.add_theme_constant_override("margin_top", 18)
|
||||||
|
margin.add_theme_constant_override("margin_bottom", 18)
|
||||||
|
patient_update_panel.add_child(margin)
|
||||||
|
var content := VBoxContainer.new()
|
||||||
|
content.add_theme_constant_override("separation", 12)
|
||||||
|
margin.add_child(content)
|
||||||
|
var heading := Label.new()
|
||||||
|
heading.text = "Patient Update"
|
||||||
|
heading.add_theme_font_size_override("font_size", 24)
|
||||||
|
content.add_child(heading)
|
||||||
|
patient_update_text = Label.new()
|
||||||
|
patient_update_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
patient_update_text.add_theme_font_size_override("font_size", 18)
|
||||||
|
content.add_child(patient_update_text)
|
||||||
|
var dismiss := Button.new()
|
||||||
|
dismiss.text = "Continue"
|
||||||
|
dismiss.custom_minimum_size = Vector2(140, 44)
|
||||||
|
dismiss.size_flags_horizontal = Control.SIZE_SHRINK_END
|
||||||
|
dismiss.pressed.connect(func() -> void: patient_update_panel.visible = false)
|
||||||
|
content.add_child(dismiss)
|
||||||
|
|
||||||
|
|
||||||
|
func _track_patient_visit_completions(chart: Array) -> void:
|
||||||
|
var current: Array[String] = []
|
||||||
|
var new_messages: Array[String] = []
|
||||||
|
for raw_entry in chart:
|
||||||
|
var entry: Dictionary = raw_entry
|
||||||
|
var code := str(entry.get("code", ""))
|
||||||
|
if str(entry.get("category", "")) != "progress" or not code.ends_with("_completed"):
|
||||||
|
continue
|
||||||
|
var key := "%s:%d" % [code, int(entry.get("simulatedMinute", 0))]
|
||||||
|
current.append(key)
|
||||||
|
if visit_completion_tracking_ready and key not in known_visit_completions:
|
||||||
|
new_messages.append(str(entry.get("text", "The patient completed a scheduled procedure and returned.")))
|
||||||
|
known_visit_completions = current
|
||||||
|
if not visit_completion_tracking_ready:
|
||||||
|
visit_completion_tracking_ready = true
|
||||||
|
return
|
||||||
|
if not new_messages.is_empty():
|
||||||
|
patient_update_text.text = "\n".join(new_messages) + "\nNew results are available in the computer."
|
||||||
|
patient_update_panel.visible = true
|
||||||
|
|
||||||
|
|
||||||
func set_orders_pending(pending: bool, message: String = "") -> void:
|
func set_orders_pending(pending: bool, message: String = "") -> void:
|
||||||
orders_confirm_button.disabled = pending or staged_order_codes.is_empty()
|
orders_confirm_button.disabled = pending or staged_order_codes.is_empty()
|
||||||
orders_advance_button.disabled = pending
|
orders_advance_button.disabled = pending
|
||||||
|
|||||||
@@ -107,16 +107,28 @@ func _run() -> void:
|
|||||||
{"code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "availableLocations": []},
|
{"code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "availableLocations": []},
|
||||||
{"code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "availableLocations": ["emergency_department", "icu"]}
|
{"code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "availableLocations": ["emergency_department", "icu"]}
|
||||||
])
|
])
|
||||||
_assert(screen.active_scene.orders_search_results.get_child_count() == 1, "order search should hide catalog entries unavailable at the current location")
|
_assert(screen.active_scene.orders_search_results.get_child_count() == 2, "order search should keep unavailable catalog entries visible")
|
||||||
|
_assert(not (screen.active_scene.orders_search_results.get_child(0) as Button).disabled, "case-authored orders should remain selectable across performance locations")
|
||||||
|
_assert((screen.active_scene.orders_search_results.get_child(1) as Button).disabled, "orders not authored for this case should explain their unavailability")
|
||||||
(screen.active_scene.orders_search_results.get_child(0) as Button).pressed.emit()
|
(screen.active_scene.orders_search_results.get_child(0) as Button).pressed.emit()
|
||||||
_assert(screen.active_scene.staged_order_codes == ["cbc"] and not screen.active_scene.orders_confirm_button.disabled, "catalog results should stage for confirmation")
|
_assert(screen.active_scene.staged_order_codes == ["cbc"] and not screen.active_scene.orders_confirm_button.disabled, "catalog results should stage for confirmation")
|
||||||
screen.active_scene.configure_orders({
|
screen.active_scene.configure_orders({
|
||||||
"state": "active", "activeOrderCodes": ["cbc"],
|
"state": "active", "activeOrderCodes": ["cbc"],
|
||||||
"activeOrders": [{"code": "cbc", "name": "Complete blood count", "route": "Blood", "frequency": "Once"}],
|
"orderOptions": [{"code": "cbc", "performanceLocation": "outpatient_laboratory", "turnaroundMinutes": 20, "requiresPatientVisit": true}],
|
||||||
|
"activeOrders": [{"code": "cbc", "name": "Complete blood count", "status": "completed", "performanceLocation": "outpatient_laboratory"}],
|
||||||
"chart": [{"category": "lab", "code": "cbc", "title": "Complete blood count", "text": "Hemoglobin 10.4 g/dL."}]
|
"chart": [{"category": "lab", "code": "cbc", "title": "Complete blood count", "text": "Hemoglobin 10.4 g/dL."}]
|
||||||
})
|
})
|
||||||
_assert(screen.active_scene.staged_order_codes.is_empty(), "confirmed active orders should leave the staged list")
|
_assert(screen.active_scene.staged_order_codes.is_empty(), "confirmed active orders should leave the staged list")
|
||||||
_assert(screen.active_scene.orders_results.text.contains("Hemoglobin 10.4"), "returned results should render in the Orders results tab")
|
_assert(screen.active_scene.orders_results.text.contains("Hemoglobin 10.4"), "returned results should render in the Orders results tab")
|
||||||
|
screen.active_scene.configure_orders({
|
||||||
|
"state": "active", "activeOrderCodes": ["cbc"], "orderOptions": [], "activeOrders": [],
|
||||||
|
"chart": [
|
||||||
|
{"category": "progress", "code": "cbc_completed", "simulatedMinute": 20, "text": "The patient completed Complete blood count at Outpatient Laboratory and returned."},
|
||||||
|
{"category": "lab", "code": "cbc", "title": "Complete blood count", "text": "Hemoglobin 10.4 g/dL."}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
_assert(screen.active_scene.patient_update_panel.visible and screen.active_scene.patient_update_text.text.contains("returned"), "completed off-site work should produce an in-scene patient update")
|
||||||
|
screen.active_scene.patient_update_panel.visible = false
|
||||||
screen.active_scene.show_order_action_complete("orders")
|
screen.active_scene.show_order_action_complete("orders")
|
||||||
_assert(screen.active_scene.orders_tabs.current_tab == 1 and screen.active_scene.orders_status.text.begins_with("Orders confirmed"), "successful submission should explain completion and open Active Orders")
|
_assert(screen.active_scene.orders_tabs.current_tab == 1 and screen.active_scene.orders_status.text.begins_with("Orders confirmed"), "successful submission should explain completion and open Active Orders")
|
||||||
screen.active_scene.show_order_action_complete("advance")
|
screen.active_scene.show_order_action_complete("advance")
|
||||||
@@ -351,6 +363,7 @@ func _sample_attempt(completed: bool) -> Dictionary:
|
|||||||
"openingCompleted": completed,
|
"openingCompleted": completed,
|
||||||
"experienceMode": "graphical_interview",
|
"experienceMode": "graphical_interview",
|
||||||
"availableExams": _sample_exams(), "chart": [],
|
"availableExams": _sample_exams(), "chart": [],
|
||||||
|
"orderOptions": [{"code": "cbc", "performanceLocation": "outpatient_laboratory", "turnaroundMinutes": 20, "requiresPatientVisit": true}],
|
||||||
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
|
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user