Skip to content

Commit 5013fcc

Browse files
committed
Merge branch 'LEGLINK-799' of https://github.com/lantanagroup/link-cloud into LEGLINK-799
2 parents e256029 + 28774bc commit 5013fcc

46 files changed

Lines changed: 3104 additions & 280 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

DotNet/Automation.Link/Validation/ReportAbsManifestValidator.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ public class ReportAbsManifestValidator
1313
private const int MaxErrors = 200;
1414
private const string ApplicablePeriodExtensionUrl = "http://www.cdc.gov/nhsn/fhirportal/dqm/ig/StructureDefinition/link-patient-list-applicable-period-extension";
1515

16+
// NHSN DQM IG profiles the Report service stamps on the manifest resources
17+
// (Report.ReportConstants.BundleSettings). Asserted here so a regression that drops
18+
// meta.profile fails the automation suite rather than the downstream IG validator.
19+
private const string DeviceProfileUrl = "http://hl7.org/fhir/us/nhsn-dqm/StructureDefinition/nhsn-submitting-device";
20+
private const string PatientListProfileUrl = "http://hl7.org/fhir/us/nhsn-dqm/StructureDefinition/poi-list";
21+
1622
/// <summary>
1723
/// Controls expected derived OperationOutcome writes per failed-validation patient.
1824
/// </summary>
@@ -323,6 +329,12 @@ private void ValidateManifest(
323329
if (deviceResources.Count != 1) AddError(errors, $"Manifest should contain exactly one Device resource. Actual={deviceResources.Count}");
324330
if (listResources.Count != 1) AddError(errors, $"Manifest should contain exactly one List resource. Actual={listResources.Count}");
325331

332+
foreach (var device in deviceResources)
333+
ValidateMetaProfile(device, "Device", DeviceProfileUrl, errors);
334+
335+
foreach (var list in listResources)
336+
ValidateMetaProfile(list, "List", PatientListProfileUrl, errors);
337+
326338
var patientList = listResources.FirstOrDefault();
327339
if (patientList.ValueKind == JsonValueKind.Undefined)
328340
{
@@ -791,6 +803,51 @@ private List<JsonElement> ParseNdjson(string ndjson, string fileName, List<strin
791803
return resources;
792804
}
793805

806+
/// <summary>
807+
/// Asserts that a manifest resource declares the NHSN DQM IG profile it is meant to conform to.
808+
/// Downstream IG validation cannot resolve the resource without it.
809+
/// </summary>
810+
private static void ValidateMetaProfile(
811+
JsonElement resource,
812+
string resourceType,
813+
string expectedProfileUrl,
814+
List<string> errors)
815+
{
816+
var profiles = GetMetaProfiles(resource);
817+
818+
if (profiles.Count == 0)
819+
{
820+
AddError(errors, $"Manifest {resourceType} is missing meta.profile. Expected '{expectedProfileUrl}'.");
821+
return;
822+
}
823+
824+
if (!profiles.Contains(expectedProfileUrl, StringComparer.Ordinal))
825+
AddError(errors, $"Manifest {resourceType} meta.profile mismatch. Expected '{expectedProfileUrl}', actual [{string.Join(", ", profiles)}].");
826+
}
827+
828+
private static List<string> GetMetaProfiles(JsonElement resource)
829+
{
830+
var profiles = new List<string>();
831+
832+
if (!resource.TryGetProperty("meta", out var meta) || meta.ValueKind != JsonValueKind.Object)
833+
return profiles;
834+
835+
if (!meta.TryGetProperty("profile", out var profileArr) || profileArr.ValueKind != JsonValueKind.Array)
836+
return profiles;
837+
838+
foreach (var profile in profileArr.EnumerateArray())
839+
{
840+
if (profile.ValueKind == JsonValueKind.String)
841+
{
842+
var value = profile.GetString();
843+
if (!string.IsNullOrWhiteSpace(value))
844+
profiles.Add(value);
845+
}
846+
}
847+
848+
return profiles;
849+
}
850+
794851
private static bool IsType(JsonElement resource, string type) =>
795852
string.Equals(GetString(resource, "resourceType"), type, StringComparison.OrdinalIgnoreCase);
796853

DotNet/Automation/Generation/FhirGenerationPipeline.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ public static async Task<PipelineResult> GenerateAndUploadAsync(
402402
// measure's MeasureReport does not contain the patient's resources, so its SDE
403403
// semantics do not contribute to the intersection of exclusions that determines
404404
// whether a resource reaches ABS.
405+
HashSet<string>? cqlFilteredKeys = null;
405406
var cqlInput = CqlFilterInputExtractor.ExtractFromEntries(patientId, entries);
406407
var effectiveProfile = profile;
407408

@@ -419,7 +420,7 @@ public static async Task<PipelineResult> GenerateAndUploadAsync(
419420
var qualifyingMeasures = measures.Where(effectiveProfile.QualifiesFor).ToList();
420421
if (qualifyingMeasures.Count > 0)
421422
{
422-
var cqlFilteredKeys = CqlFilterSimulator.ComputeFilteredKeys(qualifyingMeasures, cqlInput);
423+
cqlFilteredKeys = CqlFilterSimulator.ComputeFilteredKeys(qualifyingMeasures, cqlInput);
423424
manifestBuilder.SetCqlFilteredKeys(patientId, cqlFilteredKeys);
424425
}
425426
}
@@ -464,7 +465,8 @@ public static async Task<PipelineResult> GenerateAndUploadAsync(
464465
acquiredKeys,
465466
patientSimEntries,
466467
sharedSimEntries,
467-
acquisitionSimulation.OrganizationLocationConditionFhirPaths);
468+
acquisitionSimulation.OrganizationLocationConditionFhirPaths,
469+
cqlFilteredKeys);
468470
manifestBuilder.SetSimulatedAcquiredKeys(patientId, acquiredKeys);
469471
// patientSimEntries (JsonElement clones) are now eligible for GC
470472
}
@@ -562,6 +564,7 @@ public static async Task<PipelineResult> GenerateAndUploadAsync(
562564
var profile = new PatientProfile(eligibilities, ClinicalScenarioId: imported.DetectedClinicalScenarioId);
563565

564566
// 3. CQL filter simulation + period-aware eligibility prediction
567+
HashSet<string>? cqlFilteredKeys = null;
565568
var cqlInput = CqlFilterInputExtractor.ExtractFromEntries(patientId, entries);
566569
var effectiveProfile = profile;
567570
if (cqlInput != null)
@@ -578,7 +581,7 @@ public static async Task<PipelineResult> GenerateAndUploadAsync(
578581
var qualifyingMeasures = measures.Where(effectiveProfile.QualifiesFor).ToList();
579582
if (qualifyingMeasures.Count > 0)
580583
{
581-
var cqlFilteredKeys = CqlFilterSimulator.ComputeFilteredKeys(qualifyingMeasures, cqlInput);
584+
cqlFilteredKeys = CqlFilterSimulator.ComputeFilteredKeys(qualifyingMeasures, cqlInput);
582585
manifestBuilder.SetCqlFilteredKeys(patientId, cqlFilteredKeys);
583586
}
584587
}
@@ -617,7 +620,8 @@ public static async Task<PipelineResult> GenerateAndUploadAsync(
617620
acquiredKeys,
618621
patientSimEntries,
619622
sharedSimEntries,
620-
acquisitionSimulation.OrganizationLocationConditionFhirPaths);
623+
acquisitionSimulation.OrganizationLocationConditionFhirPaths,
624+
cqlFilteredKeys);
621625
manifestBuilder.SetSimulatedAcquiredKeys(patientId, acquiredKeys);
622626
}
623627

DotNet/Automation/Generation/GenerationManifest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ public HashSet<string> GetExpectedAbsKeysForPatient(string patientId)
284284

285285
HashSet<string>? sourceKeys = null;
286286

287-
if (SimulatedAcquiredResourceKeysByPatient.TryGetValue(patientId, out var simulated) && simulated.Count > 0)
287+
if (SimulatedAcquiredResourceKeysByPatient.TryGetValue(patientId, out var simulated))
288288
sourceKeys = simulated;
289289
else if (ResourceKeysByPatient.TryGetValue(patientId, out var generated) && generated.Count > 0)
290290
sourceKeys = generated;

DotNet/Automation/Generation/OrgResourceMapPredictionFilter.cs

Lines changed: 40 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
using System.Text.Json;
2-
using System.Text.RegularExpressions;
1+
using Hl7.Fhir.ElementModel;
2+
using Hl7.Fhir.Model;
3+
using Hl7.FhirPath;
4+
using System.Text.Json;
35

46
namespace LantanaGroup.Automation.Generation;
57

@@ -19,7 +21,8 @@ public static HashSet<string> Apply(
1921
HashSet<string> acquiredKeys,
2022
IReadOnlyList<(string ResourceType, string ResourceId, string Key, JsonElement Resource)> patientResourceEntries,
2123
IReadOnlyList<(string ResourceType, string ResourceId, string Key, JsonElement Resource)>? sharedResourceEntries,
22-
IReadOnlyList<string>? organizationLocationConditionFhirPaths)
24+
IReadOnlyList<string>? organizationLocationConditionFhirPaths,
25+
IReadOnlySet<string>? cqlFilteredKeys = null)
2326
{
2427
if (acquiredKeys.Count == 0
2528
|| organizationLocationConditionFhirPaths == null
@@ -115,12 +118,13 @@ public static HashSet<string> Apply(
115118
filtered.Add(key);
116119
}
117120

118-
return PruneUnreferencedReferenceResources(filtered, entriesByKey);
121+
return PruneUnreferencedReferenceResources(filtered, entriesByKey, cqlFilteredKeys);
119122
}
120123

121124
private static HashSet<string> PruneUnreferencedReferenceResources(
122125
HashSet<string> keptKeys,
123-
Dictionary<string, ResourceEntry> entriesByKey)
126+
Dictionary<string, ResourceEntry> entriesByKey,
127+
IReadOnlySet<string>? cqlFilteredKeys)
124128
{
125129
if (keptKeys.Count == 0)
126130
return keptKeys;
@@ -129,6 +133,7 @@ private static HashSet<string> PruneUnreferencedReferenceResources(
129133
// prediction if still referenced by kept resources after org-scope filtering.
130134
var pruneTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
131135
{
136+
"Location",
132137
"Medication",
133138
"Specimen",
134139
"Device"
@@ -142,6 +147,9 @@ private static HashSet<string> PruneUnreferencedReferenceResources(
142147
var referencedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
143148
foreach (var key in keptKeys)
144149
{
150+
if (cqlFilteredKeys?.Contains(key) == true)
151+
continue;
152+
145153
if (!entriesByKey.TryGetValue(key, out var entry))
146154
continue;
147155

@@ -196,86 +204,46 @@ private static bool LocationMatchesCondition(JsonElement locationResource, strin
196204
if (string.IsNullOrWhiteSpace(fhirPath))
197205
return false;
198206

199-
var path = fhirPath.Trim();
200-
if (path.StartsWith("Location.", StringComparison.OrdinalIgnoreCase))
201-
path = path["Location.".Length..];
202-
203-
var system = ExtractQuotedValue(path, "system");
204-
var value = ExtractQuotedValue(path, "value");
205-
var code = ExtractQuotedValue(path, "code");
206-
207-
if (path.Contains("identifier", StringComparison.OrdinalIgnoreCase)
208-
&& !string.IsNullOrWhiteSpace(system)
209-
&& locationResource.TryGetProperty("identifier", out var identifiers)
210-
&& identifiers.ValueKind == JsonValueKind.Array)
207+
try
211208
{
212-
foreach (var identifier in identifiers.EnumerateArray())
213-
{
214-
if (!identifier.TryGetProperty("system", out var sysProp) || sysProp.ValueKind != JsonValueKind.String)
215-
continue;
209+
var path = fhirPath.Trim();
210+
if (path.StartsWith("Location.", StringComparison.OrdinalIgnoreCase))
211+
path = path["Location.".Length..];
216212

217-
var identifierSystem = sysProp.GetString();
218-
if (!string.Equals(identifierSystem, system, StringComparison.OrdinalIgnoreCase))
219-
continue;
213+
var location = JsonSerializer.Deserialize<Location>(
214+
locationResource.GetRawText(),
215+
FhirSerializerOptions.ForFhirWithoutValidation());
220216

221-
if (string.IsNullOrWhiteSpace(value))
222-
return true;
217+
if (location is null)
218+
return false;
223219

224-
if (identifier.TryGetProperty("value", out var valueProp)
225-
&& valueProp.ValueKind == JsonValueKind.String
226-
&& string.Equals(valueProp.GetString(), value, StringComparison.OrdinalIgnoreCase))
227-
{
228-
return true;
229-
}
230-
}
231-
}
220+
var element = location.ToTypedElement();
221+
var compiled = new FhirPathCompiler().Compile(path);
222+
var results = compiled(element, new EvaluationContext()).ToList();
232223

233-
if (path.Contains("type.coding", StringComparison.OrdinalIgnoreCase)
234-
&& !string.IsNullOrWhiteSpace(system)
235-
&& locationResource.TryGetProperty("type", out var types)
236-
&& types.ValueKind == JsonValueKind.Array)
237-
{
238-
foreach (var type in types.EnumerateArray())
239-
{
240-
if (!type.TryGetProperty("coding", out var codingArray) || codingArray.ValueKind != JsonValueKind.Array)
241-
continue;
224+
if (results.Count == 0)
225+
return false;
242226

243-
foreach (var coding in codingArray.EnumerateArray())
244-
{
245-
if (!coding.TryGetProperty("system", out var sysProp) || sysProp.ValueKind != JsonValueKind.String)
246-
continue;
227+
if (results.Count == 1 && results[0].Value is bool isMatch)
228+
return isMatch;
247229

248-
if (!string.Equals(sysProp.GetString(), system, StringComparison.OrdinalIgnoreCase))
249-
continue;
250-
251-
if (string.IsNullOrWhiteSpace(code))
252-
return true;
253-
254-
if (coding.TryGetProperty("code", out var codeProp)
255-
&& codeProp.ValueKind == JsonValueKind.String
256-
&& string.Equals(codeProp.GetString(), code, StringComparison.OrdinalIgnoreCase))
257-
{
258-
return true;
259-
}
260-
}
261-
}
230+
return true;
231+
}
232+
catch
233+
{
234+
return false;
262235
}
263-
264-
return false;
265-
}
266-
267-
private static string? ExtractQuotedValue(string source, string key)
268-
{
269-
var match = Regex.Match(source, $@"\b{Regex.Escape(key)}\s*=\s*'([^']+)'", RegexOptions.IgnoreCase);
270-
return match.Success ? match.Groups[1].Value : null;
271236
}
272237

273238
private static bool TryGetReferencedResourceIds(JsonElement resource, string resourceType, out List<string> ids)
274239
{
275240
ids = EnumerateReferences(resource)
276-
.Where(reference => reference.StartsWith(resourceType + "/", StringComparison.OrdinalIgnoreCase))
277-
.Select(reference => reference[(resourceType.Length + 1)..])
278-
.Where(id => !string.IsNullOrWhiteSpace(id))
241+
.Select(reference => TryParseReference(reference, out var type, out var id)
242+
? (Type: type, Id: id)
243+
: (Type: string.Empty, Id: string.Empty))
244+
.Where(parsed => string.Equals(parsed.Type, resourceType, StringComparison.OrdinalIgnoreCase)
245+
&& !string.IsNullOrWhiteSpace(parsed.Id))
246+
.Select(parsed => parsed.Id)
279247
.Distinct(StringComparer.OrdinalIgnoreCase)
280248
.ToList();
281249

DotNet/DMRP/Business/Managers/FacilityReportingPlanManager.cs

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public async Task<FacilityReportingPlan> CreateAsync(FacilityReportingPlan newFa
5252

5353
ArgumentNullException.ThrowIfNull(newFacilityReportingPlan);
5454

55-
await ValidateAsync(newFacilityReportingPlan, null, cancellationToken);
55+
await ValidateAsync(newFacilityReportingPlan, cancellationToken);
5656

5757
try
5858
{
@@ -93,7 +93,7 @@ public async Task UpdateAsync(string id, FacilityReportingPlan facilityReporting
9393
throw new KeyNotFoundException($"Facility reporting plan with Id: {id} not found");
9494
}
9595

96-
await ValidateAsync(facilityReportingPlan, id, cancellationToken);
96+
await ValidateAsync(facilityReportingPlan, cancellationToken);
9797

9898
existing.FacilityId = facilityReportingPlan.FacilityId;
9999
existing.MeasureMappingId = facilityReportingPlan.MeasureMappingId;
@@ -165,11 +165,11 @@ public async Task<int> DeleteForFacilityAsync(string facilityId, CancellationTok
165165
}
166166

167167
/// <summary>
168-
/// Rejects a reporting plan that cannot be stored. <paramref name="currentId"/> is the row
169-
/// being updated, which is excluded from the duplicate check so that saving a row over itself
170-
/// is not treated as a collision.
168+
/// Rejects a reporting plan that cannot be stored. Does not check for a duplicate period:
169+
/// that is left to the unique index, and reported by <see cref="TranslateSaveFailureAsync"/>
170+
/// when the save fails.
171171
/// </summary>
172-
private async Task ValidateAsync(FacilityReportingPlan plan, string? currentId, CancellationToken cancellationToken)
172+
private async Task ValidateAsync(FacilityReportingPlan plan, CancellationToken cancellationToken)
173173
{
174174
if (string.IsNullOrWhiteSpace(plan.FacilityId))
175175
{
@@ -212,12 +212,6 @@ private async Task ValidateAsync(FacilityReportingPlan plan, string? currentId,
212212
{
213213
throw new ReportingPlanValidationException($"Facility with Id: {plan.FacilityId} not found.");
214214
}
215-
216-
if (await IsDuplicateAsync(plan, currentId, cancellationToken))
217-
{
218-
throw new DuplicateReportingPlanException(plan.FacilityId, plan.MeasureMappingId,
219-
plan.ReportingMonth, plan.ReportingYear);
220-
}
221215
}
222216

223217
private Task<bool> IsDuplicateAsync(FacilityReportingPlan plan, string? currentId, CancellationToken cancellationToken) =>

DotNet/DMRP/Business/Mapping/FacilityReportingPlanMapper.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,19 @@ public static class FacilityReportingPlanMapper
1414
ReportingMonth = entity.ReportingMonth,
1515
ReportingYear = entity.ReportingYear,
1616
IsReporting = entity.IsReporting,
17-
CreateDate = entity.CreateDate,
18-
ModifyDate = entity.ModifyDate
17+
18+
// The columns are datetime2 with no offset, so a value that round-tripped through
19+
// EF Core comes back DateTimeKind.Unspecified, while a value set in memory just
20+
// before SaveChangesAsync (UpdateBaseEntityInterceptor) is still DateTimeKind.Utc.
21+
// System.Text.Json only appends "Z" for Kind.Utc, so left unqualified, Create
22+
// (no round trip) rendered a "Z" suffix while Get/Update (fetched from the DB
23+
// first) did not - the same field serialized two different ways depending on which
24+
// operation produced it. The values are always UTC in practice, so it's safe to
25+
// pin the Kind rather than convert it.
26+
CreateDate = DateTime.SpecifyKind(entity.CreateDate, DateTimeKind.Utc),
27+
ModifyDate = entity.ModifyDate is null
28+
? null
29+
: DateTime.SpecifyKind(entity.ModifyDate.Value, DateTimeKind.Utc)
1930
};
2031

2132
public static FacilityReportingPlan ToEntity(FacilityReportingPlanRequest request) => new()

0 commit comments

Comments
 (0)