Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
f81043c
LEGLINK-828: Derive facility scheduled reports from DMRP reporting plans
MikeAtPinnacle Aug 18, 2026
0aa11f8
LEGLINK-828: Point the scheduled reports refusal at a remedy that works
MikeAtPinnacle Aug 18, 2026
030381c
TECH_DEBT: Document the DMRP controller endpoints
MikeAtPinnacle Aug 18, 2026
5b6fc3a
LEGLINK-828: Let the Admin UI create a facility when DMRP is enabled
MikeAtPinnacle Aug 18, 2026
7572df2
LEGLINK-828: Refuse a referenced measure mapping with a conflict, not…
MikeAtPinnacle Aug 19, 2026
255a051
Merge branch 'dev' into users/mtherien/leglink-709
MikeAtPinnacle Aug 19, 2026
26db3dc
LEGLINK-828: Address review findings and unblock the Backend E2E suite
MikeAtPinnacle Aug 19, 2026
e817701
LEGLINK-828: Delete a facility and its reporting plans in one transac…
MikeAtPinnacle Aug 19, 2026
503bab0
LEGLINK-828: Cover the measure mapping delete backstop and refuse a m…
MikeAtPinnacle Aug 19, 2026
8c4b241
LEGLINK-828: Set up automation facilities correctly whether or not DM…
MikeAtPinnacle Aug 19, 2026
eaaec0c
LEGLINK-828: Cover the DMRP endpoints in API Health
MikeAtPinnacle Aug 19, 2026
8e9789c
Merge branch 'dev' into users/mtherien/leglink-709
MikeAtPinnacle Aug 20, 2026
2c191f6
Merge remote-tracking branch 'origin/dev' into users/mtherien/leglink…
MikeAtPinnacle Aug 20, 2026
465336c
LEGLINK-828: Stop the facility form requiring at least one scheduled …
MikeAtPinnacle Aug 20, 2026
98a28a5
Merge branch 'dev' into users/mtherien/leglink-709
MikeAtPinnacle Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 266 additions & 15 deletions DotNet/Automation.Link/Helpers/FacilitySetupHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
using LantanaGroup.Link.Shared.Application.Enums;
using LantanaGroup.Link.Shared.Application.Models.Integration.DataAcquisition;
using LantanaGroup.Link.Shared.Application.Models.Integration.Census;
using LantanaGroup.Link.Shared.Application.Models;
using LantanaGroup.Link.Shared.Application.Models.Integration.DMRP;
using LantanaGroup.Link.Shared.Application.Models.Integration.Normalization;
using LantanaGroup.Link.Shared.Application.Models.Integration.QueryDispatch;
using LantanaGroup.Link.Shared.Application.Models.Tenant;
Expand All @@ -13,54 +15,303 @@ namespace LantanaGroup.Link.Automation.Link.Helpers;

public static class FacilitySetupHelper
{
/// <summary>
/// The timezone every automation facility is created in. The DMRP reporting period is read in it,
/// so the two cannot be allowed to drift apart.
/// </summary>
private const string FacilityTimeZone = "America/Chicago";

public static async Task EnsureFacilityAsync(
IFacilityServiceClient facilityClient,
IDmrpServiceClient dmrpClient,
IAutomationOutput output,
string facilityId,
string? measureId)
string? measureId,
CancellationToken cancellationToken = default)
{
await EnsureFacilityAsync(facilityClient, output, facilityId,
measureId != null ? [measureId] : []);
await EnsureFacilityAsync(facilityClient, dmrpClient, output, facilityId,
measureId != null ? [measureId] : [], cancellationToken);
}

/// <summary>
/// Creates the facility the run reports for, scheduled to report <paramref name="measureIds"/>
/// monthly.
/// </summary>
/// <remarks>
/// How that schedule gets set depends on whether the Tenant service is hosting the DMRP module.
/// With DMRP off it is posted with the facility. With DMRP on it is not the caller's to give β€”
/// Tenant derives it from the facility's DMRP reporting plans and refuses a request that carries
/// one β€” so the same schedule has to be reached by enrolling the facility in those measures and
/// letting Tenant derive it. Both paths leave the same monthly schedule behind, which is what the
/// rest of the run and the tenant database validator expect.
/// </remarks>
public static async Task EnsureFacilityAsync(
IFacilityServiceClient facilityClient,
IDmrpServiceClient dmrpClient,
IAutomationOutput output,
string facilityId,
List<string> measureIds)
List<string> measureIds,
CancellationToken cancellationToken = default)
{
var existing = await facilityClient.GetAsync(facilityId);
var existing = await facilityClient.GetAsync(facilityId, cancellationToken);
if (existing.IsSuccessStatusCode && existing.Body != null)
{
output.WriteLine($"Facility '{facilityId}' already exists. Skipping create.");
await WaitForFacilityReadConsistencyAsync(facilityClient, output, facilityId);
await WaitForFacilityReadConsistencyAsync(facilityClient, output, facilityId, cancellationToken);
return;
}

var dmrpEnabled = await DmrpIsEnabledAsync(dmrpClient, output, cancellationToken);

var createResponse = await facilityClient.CreateAsync(new FacilityModel
{
FacilityId = facilityId,
FacilityName = facilityId,
TimeZone = "America/Chicago",
TimeZone = FacilityTimeZone,
Vendor = new VendorModel
{
Name = "Epic"
},
ScheduledReports = new TenantScheduledReportConfig
{
Monthly = measureIds.ToArray(),
Daily = [],
Weekly = []
}
});
// Empty under DMRP, and not merely unselected: a request that names any report is refused
// outright. The measures are enrolled below instead.
ScheduledReports = MonthlySchedule(dmrpEnabled ? [] : measureIds)
}, cancellationToken);

if (!createResponse.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"Failed to create facility '{facilityId}'. HTTP {createResponse.StatusCode}: {createResponse.RawBody ?? "(no body)"}");
}

await WaitForFacilityReadConsistencyAsync(facilityClient, output, facilityId);
await WaitForFacilityReadConsistencyAsync(facilityClient, output, facilityId, cancellationToken);

if (dmrpEnabled)
{
await EnrollFacilityInDmrpMeasuresAsync(facilityClient, dmrpClient, output, facilityId,
measureIds, cancellationToken);
}
}

private static TenantScheduledReportConfig MonthlySchedule(IReadOnlyList<string> measureIds) => new()
{
Monthly = measureIds.ToArray(),
Daily = [],
Weekly = []
};

/// <summary>
/// Whether the Tenant service is hosting the DMRP module.
/// </summary>
/// <remarks>
/// Asked rather than configured. A disabled module strips its own controllers from the host, so
/// its routes answer 404 and there is nothing else to read β€” which keeps this from becoming a
/// third place the flag has to be set and kept in step with the stack and the Admin UI.
/// </remarks>
private static async Task<bool> DmrpIsEnabledAsync(
IDmrpServiceClient dmrpClient,
IAutomationOutput output,
CancellationToken cancellationToken)
{
var probe = await dmrpClient.SearchFacilityReportingPlansAsync(pageSize: 1, pageNumber: 1,
cancellationToken: cancellationToken);

if (probe.StatusCode == (int)HttpStatusCode.NotFound)
{
output.WriteLine("DMRP is not enabled on the Tenant service; the facility's schedule is posted with it.");
return false;
}

if (!probe.IsSuccessStatusCode)
{
// Neither answer. Guessing either way strands the run β€” at facility create if DMRP is on,
// reporting nothing if it is off β€” so name the request that failed instead.
throw new InvalidOperationException(
"Could not determine whether DMRP is enabled on the Tenant service. " +
$"GET api/dmrp/reporting-plans returned HTTP {probe.StatusCode}: {probe.RawBody ?? "(no body)"}");
}

output.WriteLine("DMRP is enabled on the Tenant service; the facility's schedule is derived from its reporting plans.");
return true;
}

/// <summary>
/// Enrolls the facility in each measure and has Tenant derive its schedule from that enrollment.
/// </summary>
/// <remarks>
/// The order is forced from both ends: the schedule is derived when the facility is saved, so the
/// reporting plans have to exist first, but a reporting plan is refused for a facility that does
/// not exist yet. Neither can go first, so the facility is created with an empty schedule and
/// saved a second time once there is something to derive one from.
/// </remarks>
private static async Task EnrollFacilityInDmrpMeasuresAsync(
IFacilityServiceClient facilityClient,
IDmrpServiceClient dmrpClient,
IAutomationOutput output,
string facilityId,
List<string> measureIds,
CancellationToken cancellationToken)
{
if (measureIds.Count == 0)
{
output.WriteLine($"No measures to enroll facility '{facilityId}' in; it is scheduled for no reports.");
return;
}

foreach (var measureId in measureIds)
{
var mappingId = await EnsureMeasureMappingAsync(dmrpClient, output, measureId, cancellationToken);

foreach (var (month, year) in ReportingPeriods())
{
await EnsureReportingPlanAsync(dmrpClient, output, facilityId, mappingId, month, year,
cancellationToken);
}
}

var updated = await facilityClient.UpdateAsync(facilityId, new FacilityModel
{
FacilityId = facilityId,
FacilityName = facilityId,
TimeZone = FacilityTimeZone,
Vendor = new VendorModel
{
Name = "Epic"
},
ScheduledReports = MonthlySchedule([])
}, cancellationToken);

if (!updated.IsSuccessStatusCode)
{
throw new InvalidOperationException(
$"Failed to derive the DMRP schedule for facility '{facilityId}'. HTTP {updated.StatusCode}: {updated.RawBody ?? "(no body)"}");
}

output.WriteLine(
$"Enrolled facility '{facilityId}' in {measureIds.Count} DMRP measure(s); its schedule is derived from its reporting plans.");
}

/// <summary>
/// The reporting periods to enroll the facility for: the one it is in, and the one after it.
/// </summary>
/// <remarks>
/// Enrollment is recorded per period, and Tenant derives the schedule for whichever period the
/// facility is in when it is saved. A run that crosses midnight on the first of a month between
/// the two saves would otherwise derive an empty schedule from a period nothing was enrolled for,
/// then fail much later with an error about scheduled reports rather than about the clock.
/// </remarks>
private static IReadOnlyList<(int Month, int Year)> ReportingPeriods()
{
var now = FacilityLocalNow();
var next = now.AddMonths(1);

return [(now.Month, now.Year), (next.Month, next.Year)];
}

private static DateTimeOffset FacilityLocalNow()
{
var utcNow = DateTimeOffset.UtcNow;

try
{
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(FacilityTimeZone);
return TimeZoneInfo.ConvertTime(utcNow, timeZone);
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
// Tenant falls back to UTC for a timezone it cannot read, so match it rather than enroll
// the facility for a period its schedule will never be derived from.
return utcNow;
}
}

private static async Task<string> EnsureMeasureMappingAsync(
IDmrpServiceClient dmrpClient,
IAutomationOutput output,
string measureId,
CancellationToken cancellationToken)
{
var existingId = await FindMeasureMappingAsync(dmrpClient, measureId, cancellationToken);
if (existingId != null)
{
output.WriteLine($"DMRP measure mapping for '{measureId}' already exists. Reusing it.");
return existingId;
}

// Measure and dQM are deliberately the same value. The run drives the pipeline with the
// measure's own id, so that is what the derived schedule has to name for the schedule and the
// report types to agree. Monthly matches the frequency the non-DMRP path posts.
var created = await dmrpClient.CreateMeasureMappingAsync(new MeasureMappingModel
{
Measure = measureId,
DQM = measureId,
Frequency = Frequency.Monthly
}, cancellationToken);

if (created.IsSuccessStatusCode && !string.IsNullOrWhiteSpace(created.Body?.Id))
{
return created.Body!.Id!;
}

// Mappings are shared by every run against a stack, so a run starting alongside another can
// lose the race to create one. Losing it is not a failure β€” the mapping it needed now exists.
if (created.StatusCode == (int)HttpStatusCode.BadRequest)
{
existingId = await FindMeasureMappingAsync(dmrpClient, measureId, cancellationToken);
if (existingId != null)
{
output.WriteLine($"DMRP measure mapping for '{measureId}' was created concurrently. Reusing it.");
return existingId;
}
}

throw new InvalidOperationException(
$"Failed to create DMRP measure mapping for measure '{measureId}'. HTTP {created.StatusCode}: {created.RawBody ?? "(no body)"}");
}

private static async Task<string?> FindMeasureMappingAsync(
IDmrpServiceClient dmrpClient,
string measureId,
CancellationToken cancellationToken)
{
// Both filters match exactly, and measure and dQM together are unique, so this is at most one
// row. Nothing matching answers 204 with no body rather than an empty page.
var search = await dmrpClient.SearchMeasureMappingsAsync(measure: measureId, dqm: measureId,
pageSize: 1, pageNumber: 1, cancellationToken: cancellationToken);

return search.Body?.Records?.FirstOrDefault()?.Id;
}

private static async Task EnsureReportingPlanAsync(
IDmrpServiceClient dmrpClient,
IAutomationOutput output,
string facilityId,
string measureMappingId,
int month,
int year,
CancellationToken cancellationToken)
{
var created = await dmrpClient.CreateFacilityReportingPlanAsync(new FacilityReportingPlanRequest
{
FacilityId = facilityId,
MeasureMappingId = measureMappingId,
ReportingMonth = month,
ReportingYear = year,
IsReporting = true
}, cancellationToken);

if (created.IsSuccessStatusCode)
{
return;
}

if (created.StatusCode == (int)HttpStatusCode.Conflict)
{
output.WriteLine($"DMRP reporting plan for facility '{facilityId}' and {month}/{year} already exists. Skipping create.");
return;
}

throw new InvalidOperationException(
$"Failed to create DMRP reporting plan for facility '{facilityId}' ({month}/{year}). HTTP {created.StatusCode}: {created.RawBody ?? "(no body)"}");
}

private static async Task WaitForFacilityReadConsistencyAsync(
Expand Down
1 change: 1 addition & 0 deletions DotNet/Automation.UI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ static IReadOnlyCollection<string> BuildValidAudiences(string configuredAudience
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.QueryDispatchTestSuite>();
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.SubmissionServiceTestSuite>();
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.MeasureEvalTestSuite>();
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.DmrpTestSuite>();
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.ValidationServiceTestSuite>();
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.AdminBffTestSuite>();

Expand Down
Loading
Loading