Skip to content

Commit 77528e1

Browse files
LEGLINK-828: Tenant service integration with the DMRP module (#1848)
* LEGLINK-828: Derive facility scheduled reports from DMRP reporting plans The Tenant facility endpoints now resolve their state-changing operations through IFacilityOperations, which the DMRP module replaces with its own implementation when DMRP:Enabled is set. Routes and the BFF reverse proxy are unchanged - only the behavior behind POST, PUT and DELETE differs. With DMRP enabled: - Create and update refuse a caller-supplied scheduledReports block and derive the schedule from the facility's reporting plans instead, grouping the mapped dQMs by the frequency their measure mapping carries. A measure with no dQM mapped is logged and excluded rather than silently dropped. - Hard delete removes the facility first, then its reporting plans, so plans survive a delete the host refuses. Soft delete keeps them, since the facility can be restored. - The reporting period is read in the facility's own timezone, so a facility near a month boundary is scheduled against the month it is actually in. Plans are read from the FacilityReportingPlans table behind IReportingPlanSource. An implementation that refreshes those rows from the DMRP API takes its place with LEGLINK-698/701, and nothing consuming the interface changes. The module takes over the IFacilityOperations registration and delegates to the host's implementation, which it names through a type parameter, so a host that does not supply one fails to compile rather than at startup. Also adds a facility lifecycle test asserting Quartz job presence across create, soft delete, restore and delete. The existing controller tests assert status codes only, so scheduling could be dropped from any of those paths unnoticed. * LEGLINK-828: Point the scheduled reports refusal at a remedy that works End-to-end testing showed the refusal named something the caller cannot do. TenantScheduledReportConfig's Daily, Weekly and Monthly are non-nullable, so a request that leaves scheduledReports out is rejected during model binding with "The Daily field is required" before the DMRP module runs. Telling the caller to resubmit without the block sent them to a different 400 with no way forward. An empty block is what gets through, so the message asks for that instead. Adds tests pinning both halves of the contract: an explicitly empty block is accepted rather than treated as a caller-supplied schedule, and the refusal names the empty-array remedy. Neither is reachable from a unit test of the operations alone - the binding failure sits above where they call in - so the message could drift back without the second one. * TECH_DEBT: Document the DMRP controller endpoints Both DMRP controllers carried a one-line summary per action and nothing else, so the parameter ranges, the validation rules and the meaning of each status code lived only in the code. All fifteen endpoints now carry param and response documentation, which Swagger picks up through the DMRP XML file the Tenant service already feeds it. The same text is on the matching requests in the Link Admin BFF Postman collection. Behavior that was previously undocumented and is easy to get wrong: - Reporting plans refuse out-of-range paging rather than clamping it, while measure mappings quietly replace it with the default. The two controllers disagree, so both now say which they do. - An empty measure mapping search answers 204, while an empty reporting plan search answers 200 with an empty page. - A measure mapping created without a frequency defaults to Adhoc, which schedules nothing. - Deleting a measure mapping that reporting plans still reference answers 404 rather than a conflict, because the manager reports a refused delete and a missing row as the same failure. Distinguishable only by the message. Also drops the 404 ProducesResponseType from DeleteAllMeasureMappings, which has no path that returns one. * LEGLINK-828: Let the Admin UI create a facility when DMRP is enabled With DMRP enabled the Tenant API derives a facility's scheduled reports from its DMRP reporting plans and refuses a request that supplies its own. The facility form did the opposite: ScheduledReportsValidator required at least one report, so no input satisfied both and facility create and edit were impossible. The local docker stack has been in that state since DMRP__Enabled was set true there; no deployed environment is affected, because DMRP:Enabled is absent from every store and defaults to false. The form now asks the flag what to do: - ScheduledReportsValidator takes it and drops the "at least one report" rule when DMRP is on. Duplicates are still rejected either way. - The report pickers are replaced by a line saying the schedule is derived. - Submit sends empty arrays. Editing an existing facility loads its stored schedule into the controls, so they are emptied explicitly rather than assumed untouched. The block itself is still sent: its arrays are not nullable, so omitting it fails model binding before the API sees it. DMRP:Enabled moves from the Tenant service section of the config catalog to global. It is one decision for the deployment rather than one service's setting, and unlabeled means every service reads the same answer. The Angular app cannot read App Configuration, so the same decision reaches it through the container's LINK_DMRP_ENABLED. The two must agree, and the failure modes are not symmetric: the UI switched on ahead of the services quietly creates facilities that report nothing, while the reverse fails loudly. The catalog and the code both say so. The flag is temporary and expected to end up permanently on, so it is written to be deleted: every site is marked "DMRP feature flag", the removal recipe is on AppConfig.dmrpEnabled, and the validator parameter defaults to the state the flag settles on, with a test pinning that. * LEGLINK-828: Refuse a referenced measure mapping with a conflict, not a not-found Deleting a measure mapping that facility reporting plans still reference answered 404. The row plainly exists and is readable, so reporting it as missing tells the caller the opposite of what happened. The manager wrapped every save failure in ApplicationException, which the controller could only map one way. The mapping is now asked about before it is deleted: if any reporting plan references it, the request is refused with 409 and a message naming the reason. Delete-all is refused the same way while any plan exists, checked before anything is removed so a refusal deletes nothing rather than part of the table. A mapping that genuinely does not exist still answers 404. The check is a query rather than a translated database error because the error is not stable enough to classify on. Which one the database raises depends on EF's change tracker: with the dependent untracked the DELETE reaches the database and the foreign key fires (SQL Server 547, SQLite 787), but with it tracked EF first tries to sever the relationship by nulling MeasureMappingId, which the NOT NULL column rejects instead (SQL Server 515, SQLite 1299). Classifying on the code alone would have behaved differently in a controller request than in a test. The translation is kept as a backstop for the window between the check and the delete, now covering all four codes. Also adds ValidateAntiForgeryOrBearerToken to DeleteMeasureMapping, which was the only write endpoint on the controller without it. Both defects were found while documenting these endpoints and were called out then as belonging to the measure mappings work rather than the DMRP facility integration. * LEGLINK-828: Address review findings and unblock the Backend E2E suite Log arguments in the two new DMRP files called Sanitize(), the HTML sanitizer. It strips markup but leaves CR/LF, so a facilityId or measure containing a newline could still forge log lines. Both values come from request bodies. They now call SanitizeForLog(), which replaces control characters, matching every sibling in the module. Five call sites across DbBackedReportingPlanSource and DmrpFacilityOperations. The DeleteAllAsync pre-check added earlier refuses while any reporting plan exists, which turned a leaked row into an unrelated failing test: the three DMRP integration classes share one SQLite file, run in an unspecified order, and cleared plans in their constructor only. Cleanup now also runs in Dispose, so a class cannot leak rows into whichever runs next. Adds the two tests the new branches were missing: - DeleteAllMeasureMappings refused with 409 while a reporting plan exists, also asserting an unreferenced mapping survives the refusal. - The facility form with DMRP enabled, covering the case the conditional exists for: editing a facility loads its stored schedule into the report controls, so submitting an otherwise untouched facility has to send empty arrays rather than hand the stored schedule back to an API that refuses it. Turns DMRP off in the docker stack. FacilitySetupHelper creates facilities with a schedule of its own, which the refusal rejects, so enabling DMRP there broke the Backend E2E suite. DMRP:Enabled in appsettings.Docker.json and LINK_DMRP_ENABLED in docker-compose move to false together - the UI switched on ahead of the services is the silent failure mode, so they are never flipped separately. The compose comment records what to set to exercise DMRP locally. * LEGLINK-828: Delete a facility and its reporting plans in one transaction Hard delete removed the facility, then its reporting plans, with nothing tying the two together. A failure after the facility row was gone stranded the plans: nothing ever collected them, they blocked measure mapping deletes, and a facility later created with the same id silently inherited a previous incarnation's schedule. Both persist through the host's database context - the facility repository and the reporting plan repository resolve the same scoped instance - so one transaction covers them. The order is unchanged, since the host's delete can still refuse and plans removed ahead of a refused delete would leave a facility that reports nothing. Quartz keeps its own store and cannot enlist, so a rollback leaves the restored facility without its scheduled jobs. That was the argument against a transaction here, and it does not hold: ScheduleService.StartAsync rebuilds jobs for every facility that is not deleted, and DeleteJobsForFacility is idempotent, so the gap closes on the next restart or delete retry. A stranded reporting plan closes on nothing. The rollback is guarded. If it fails it is logged with the facility and the endpoint that clears its plans, and the original exception is rethrown - the caller needs to hear about the delete that failed, not the cleanup that failed afterwards. Tests cover the transaction protocol in order, rollback without commit when the plan cleanup throws, and the original exception surviving a failing rollback. * LEGLINK-828: Cover the measure mapping delete backstop and refuse a mis-ordered module registration - Add unit coverage for MeasureMappingManager's IsStillReferenced backstop, which handles the window between the pre-check and the delete. A SQLite foreign key failure (787) and a not-null failure (1299) both surface as MeasureMappingInUseException, a failure buried two levels deep still does, and an unrelated failure does not. SQL Server's 547 and 515 cannot be reached from a test: SqlException has no public constructor and is only ever produced by the driver. - Refuse AddDmrpModule when the host has registered neither IFacilityOperations nor its implementation type. RemoveAll on a service nobody registered removes nothing and reports nothing, so calling the module before the host registered its operations left the host's registration appended afterwards and winning the resolve: DMRP enabled with none of its facility behavior and no sign of it. - Give the missing-dQM exclusion test a frequency the schedule has a bucket for, so the dQM filter is what excludes the entry rather than the frequency mismatch, and cover the adhoc frequency in a test of its own. - Drop a single-use intermediate list when logging unmapped measures. * LEGLINK-828: Set up automation facilities correctly whether or not DMRP is enabled The automation stack posted a facility's scheduled reports with the facility, which DMRP refuses, so every Automation.UI scenario run failed at setup with the module enabled. The Backend E2E suites are thin clients of Automation.UI's run API, so they failed for the same reason and in the same place. - Detect DMRP by asking rather than by configuration. A disabled module strips its own controllers from the host, so api/dmrp/reporting-plans answers 404 when it is off and 200 when it is on. An answer that is neither stops the run naming the request that failed, rather than guessing and stranding it later. - With DMRP off, post the schedule with the facility exactly as before. With it on, create the facility with an empty schedule, enroll it in a measure mapping per measure, then save it again so Tenant derives the schedule from those reporting plans. Neither ordering works on its own: the schedule is derived when the facility is saved, but a reporting plan is refused for a facility that does not exist yet. - Map each measure to itself monthly. The run drives the pipeline with the measure's own id, so the derived schedule names what the report types name and both paths leave the same facility behind. That is what keeps the tenant database validator unchanged. - Enroll for the following reporting period as well as the current one, so a run that crosses midnight on the first of a month does not derive an empty schedule from a period nothing was enrolled for. - Reuse a measure mapping that already exists, including one created concurrently. Mappings are shared by every run against a stack, so creating one is a race a run can lose without having failed. - Fix SearchMeasureMappingsAsync, which read the measure-mapping collection route. That route only accepts POST, so the call answered 404 - indistinguishable from the module being switched off - and it had no consumers to notice. Add the measure, dQM and frequency filters the API already supports. - Correct the DMRP switch comment in docker-compose.yml: the E2E suite no longer forces the module off, and appsettings is baked into the tenant image, so changing the flag needs a rebuild rather than just recreating the container. * LEGLINK-828: Cover the DMRP endpoints in API Health The DMRP module's endpoints were the only service surface API Health did not exercise, so the behaviours added for this story - a conflict when deleting a referenced measure mapping, a refused facility schedule, no-content on an empty search - sat outside the suite CI runs. - Add a DMRP suite covering measure mapping and reporting plan CRUD, their error paths, and the one endpoint DMRP changes without owning: a facility that carries its own schedule is refused while the module is enabled. - Ask the Tenant service whether DMRP is registered rather than reading a flag. A disabled module strips its own controllers, so its routes answer 404, and the suite reports its steps as skipped rather than failing a stack that simply is not running DMRP. This keeps the switch in one place instead of adding a copy here. - Declare no seed requirement. Seeding runs a full pipeline scenario before any suite executes, which is a minute or two of waiting that buys this suite nothing and is wasted entirely whenever DMRP is switched off. The two fixtures it needs are cheap to obtain directly: a dQM MeasureEval already holds, and a facility of its own that it removes when it is done. Running the suite drops from about two minutes to one second. - Stop counting skipped steps against a service on the API Health dashboard. The badge showed passed-over-total with skipped steps in the denominator and the same grey as a run in progress, so a service whose steps all skipped read as one that was still going. Skipped steps now leave the denominator and a fully skipped service says so. Steps that never ran stay in it, so pending and skipped no longer look alike. * LEGLINK-828: Stop the facility form requiring at least one scheduled report The rule predated DMRP and is removed at a reviewer's suggestion on this PR. It also could not coexist with DMRP: the schedule is derived from a facility's reporting plans and the Tenant API refuses a facility that supplies its own, so with the rule in force there was no input the form could produce that the API would accept. The DMRP work routed around it by making the rule conditional; removing it outright takes the contradiction out at the source. - ScheduledReportsValidator now checks one thing, that no report is named twice, and no longer takes the DMRP flag. Its errors accumulator went with the second rule. - Drop the noReportsEntered getter and the error it rendered on the facility form. - Cover a duplicate repeated within a single period, which the spec had not exercised. The DMRP flag stays in the UI: it still hides the report pickers and submits an empty schedule, the latter mattering on edit, where the form loads a facility's stored schedule into the controls and would otherwise send it back to an API that refuses it. Its retirement recipe loses the step for the validator parameter. Note this also applies with DMRP disabled, where the form was the only thing preventing a facility from being saved with no scheduled reports. The API has always allowed it - ScheduleService treats an empty array as "create no job for this frequency" - so such a facility can now be created from the UI and will run no scheduled reports.
1 parent 909a7fb commit 77528e1

44 files changed

Lines changed: 3687 additions & 160 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/Helpers/FacilitySetupHelper.cs

Lines changed: 266 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
using LantanaGroup.Link.Shared.Application.Enums;
44
using LantanaGroup.Link.Shared.Application.Models.Integration.DataAcquisition;
55
using LantanaGroup.Link.Shared.Application.Models.Integration.Census;
6+
using LantanaGroup.Link.Shared.Application.Models;
7+
using LantanaGroup.Link.Shared.Application.Models.Integration.DMRP;
68
using LantanaGroup.Link.Shared.Application.Models.Integration.Normalization;
79
using LantanaGroup.Link.Shared.Application.Models.Integration.QueryDispatch;
810
using LantanaGroup.Link.Shared.Application.Models.Tenant;
@@ -13,54 +15,303 @@ namespace LantanaGroup.Link.Automation.Link.Helpers;
1315

1416
public static class FacilitySetupHelper
1517
{
18+
/// <summary>
19+
/// The timezone every automation facility is created in. The DMRP reporting period is read in it,
20+
/// so the two cannot be allowed to drift apart.
21+
/// </summary>
22+
private const string FacilityTimeZone = "America/Chicago";
23+
1624
public static async Task EnsureFacilityAsync(
1725
IFacilityServiceClient facilityClient,
26+
IDmrpServiceClient dmrpClient,
1827
IAutomationOutput output,
1928
string facilityId,
20-
string? measureId)
29+
string? measureId,
30+
CancellationToken cancellationToken = default)
2131
{
22-
await EnsureFacilityAsync(facilityClient, output, facilityId,
23-
measureId != null ? [measureId] : []);
32+
await EnsureFacilityAsync(facilityClient, dmrpClient, output, facilityId,
33+
measureId != null ? [measureId] : [], cancellationToken);
2434
}
2535

36+
/// <summary>
37+
/// Creates the facility the run reports for, scheduled to report <paramref name="measureIds"/>
38+
/// monthly.
39+
/// </summary>
40+
/// <remarks>
41+
/// How that schedule gets set depends on whether the Tenant service is hosting the DMRP module.
42+
/// With DMRP off it is posted with the facility. With DMRP on it is not the caller's to give —
43+
/// Tenant derives it from the facility's DMRP reporting plans and refuses a request that carries
44+
/// one — so the same schedule has to be reached by enrolling the facility in those measures and
45+
/// letting Tenant derive it. Both paths leave the same monthly schedule behind, which is what the
46+
/// rest of the run and the tenant database validator expect.
47+
/// </remarks>
2648
public static async Task EnsureFacilityAsync(
2749
IFacilityServiceClient facilityClient,
50+
IDmrpServiceClient dmrpClient,
2851
IAutomationOutput output,
2952
string facilityId,
30-
List<string> measureIds)
53+
List<string> measureIds,
54+
CancellationToken cancellationToken = default)
3155
{
32-
var existing = await facilityClient.GetAsync(facilityId);
56+
var existing = await facilityClient.GetAsync(facilityId, cancellationToken);
3357
if (existing.IsSuccessStatusCode && existing.Body != null)
3458
{
3559
output.WriteLine($"Facility '{facilityId}' already exists. Skipping create.");
36-
await WaitForFacilityReadConsistencyAsync(facilityClient, output, facilityId);
60+
await WaitForFacilityReadConsistencyAsync(facilityClient, output, facilityId, cancellationToken);
3761
return;
3862
}
3963

64+
var dmrpEnabled = await DmrpIsEnabledAsync(dmrpClient, output, cancellationToken);
65+
4066
var createResponse = await facilityClient.CreateAsync(new FacilityModel
4167
{
4268
FacilityId = facilityId,
4369
FacilityName = facilityId,
44-
TimeZone = "America/Chicago",
70+
TimeZone = FacilityTimeZone,
4571
Vendor = new VendorModel
4672
{
4773
Name = "Epic"
4874
},
49-
ScheduledReports = new TenantScheduledReportConfig
50-
{
51-
Monthly = measureIds.ToArray(),
52-
Daily = [],
53-
Weekly = []
54-
}
55-
});
75+
// Empty under DMRP, and not merely unselected: a request that names any report is refused
76+
// outright. The measures are enrolled below instead.
77+
ScheduledReports = MonthlySchedule(dmrpEnabled ? [] : measureIds)
78+
}, cancellationToken);
5679

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

63-
await WaitForFacilityReadConsistencyAsync(facilityClient, output, facilityId);
86+
await WaitForFacilityReadConsistencyAsync(facilityClient, output, facilityId, cancellationToken);
87+
88+
if (dmrpEnabled)
89+
{
90+
await EnrollFacilityInDmrpMeasuresAsync(facilityClient, dmrpClient, output, facilityId,
91+
measureIds, cancellationToken);
92+
}
93+
}
94+
95+
private static TenantScheduledReportConfig MonthlySchedule(IReadOnlyList<string> measureIds) => new()
96+
{
97+
Monthly = measureIds.ToArray(),
98+
Daily = [],
99+
Weekly = []
100+
};
101+
102+
/// <summary>
103+
/// Whether the Tenant service is hosting the DMRP module.
104+
/// </summary>
105+
/// <remarks>
106+
/// Asked rather than configured. A disabled module strips its own controllers from the host, so
107+
/// its routes answer 404 and there is nothing else to read — which keeps this from becoming a
108+
/// third place the flag has to be set and kept in step with the stack and the Admin UI.
109+
/// </remarks>
110+
private static async Task<bool> DmrpIsEnabledAsync(
111+
IDmrpServiceClient dmrpClient,
112+
IAutomationOutput output,
113+
CancellationToken cancellationToken)
114+
{
115+
var probe = await dmrpClient.SearchFacilityReportingPlansAsync(pageSize: 1, pageNumber: 1,
116+
cancellationToken: cancellationToken);
117+
118+
if (probe.StatusCode == (int)HttpStatusCode.NotFound)
119+
{
120+
output.WriteLine("DMRP is not enabled on the Tenant service; the facility's schedule is posted with it.");
121+
return false;
122+
}
123+
124+
if (!probe.IsSuccessStatusCode)
125+
{
126+
// Neither answer. Guessing either way strands the run — at facility create if DMRP is on,
127+
// reporting nothing if it is off — so name the request that failed instead.
128+
throw new InvalidOperationException(
129+
"Could not determine whether DMRP is enabled on the Tenant service. " +
130+
$"GET api/dmrp/reporting-plans returned HTTP {probe.StatusCode}: {probe.RawBody ?? "(no body)"}");
131+
}
132+
133+
output.WriteLine("DMRP is enabled on the Tenant service; the facility's schedule is derived from its reporting plans.");
134+
return true;
135+
}
136+
137+
/// <summary>
138+
/// Enrolls the facility in each measure and has Tenant derive its schedule from that enrollment.
139+
/// </summary>
140+
/// <remarks>
141+
/// The order is forced from both ends: the schedule is derived when the facility is saved, so the
142+
/// reporting plans have to exist first, but a reporting plan is refused for a facility that does
143+
/// not exist yet. Neither can go first, so the facility is created with an empty schedule and
144+
/// saved a second time once there is something to derive one from.
145+
/// </remarks>
146+
private static async Task EnrollFacilityInDmrpMeasuresAsync(
147+
IFacilityServiceClient facilityClient,
148+
IDmrpServiceClient dmrpClient,
149+
IAutomationOutput output,
150+
string facilityId,
151+
List<string> measureIds,
152+
CancellationToken cancellationToken)
153+
{
154+
if (measureIds.Count == 0)
155+
{
156+
output.WriteLine($"No measures to enroll facility '{facilityId}' in; it is scheduled for no reports.");
157+
return;
158+
}
159+
160+
foreach (var measureId in measureIds)
161+
{
162+
var mappingId = await EnsureMeasureMappingAsync(dmrpClient, output, measureId, cancellationToken);
163+
164+
foreach (var (month, year) in ReportingPeriods())
165+
{
166+
await EnsureReportingPlanAsync(dmrpClient, output, facilityId, mappingId, month, year,
167+
cancellationToken);
168+
}
169+
}
170+
171+
var updated = await facilityClient.UpdateAsync(facilityId, new FacilityModel
172+
{
173+
FacilityId = facilityId,
174+
FacilityName = facilityId,
175+
TimeZone = FacilityTimeZone,
176+
Vendor = new VendorModel
177+
{
178+
Name = "Epic"
179+
},
180+
ScheduledReports = MonthlySchedule([])
181+
}, cancellationToken);
182+
183+
if (!updated.IsSuccessStatusCode)
184+
{
185+
throw new InvalidOperationException(
186+
$"Failed to derive the DMRP schedule for facility '{facilityId}'. HTTP {updated.StatusCode}: {updated.RawBody ?? "(no body)"}");
187+
}
188+
189+
output.WriteLine(
190+
$"Enrolled facility '{facilityId}' in {measureIds.Count} DMRP measure(s); its schedule is derived from its reporting plans.");
191+
}
192+
193+
/// <summary>
194+
/// The reporting periods to enroll the facility for: the one it is in, and the one after it.
195+
/// </summary>
196+
/// <remarks>
197+
/// Enrollment is recorded per period, and Tenant derives the schedule for whichever period the
198+
/// facility is in when it is saved. A run that crosses midnight on the first of a month between
199+
/// the two saves would otherwise derive an empty schedule from a period nothing was enrolled for,
200+
/// then fail much later with an error about scheduled reports rather than about the clock.
201+
/// </remarks>
202+
private static IReadOnlyList<(int Month, int Year)> ReportingPeriods()
203+
{
204+
var now = FacilityLocalNow();
205+
var next = now.AddMonths(1);
206+
207+
return [(now.Month, now.Year), (next.Month, next.Year)];
208+
}
209+
210+
private static DateTimeOffset FacilityLocalNow()
211+
{
212+
var utcNow = DateTimeOffset.UtcNow;
213+
214+
try
215+
{
216+
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(FacilityTimeZone);
217+
return TimeZoneInfo.ConvertTime(utcNow, timeZone);
218+
}
219+
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
220+
{
221+
// Tenant falls back to UTC for a timezone it cannot read, so match it rather than enroll
222+
// the facility for a period its schedule will never be derived from.
223+
return utcNow;
224+
}
225+
}
226+
227+
private static async Task<string> EnsureMeasureMappingAsync(
228+
IDmrpServiceClient dmrpClient,
229+
IAutomationOutput output,
230+
string measureId,
231+
CancellationToken cancellationToken)
232+
{
233+
var existingId = await FindMeasureMappingAsync(dmrpClient, measureId, cancellationToken);
234+
if (existingId != null)
235+
{
236+
output.WriteLine($"DMRP measure mapping for '{measureId}' already exists. Reusing it.");
237+
return existingId;
238+
}
239+
240+
// Measure and dQM are deliberately the same value. The run drives the pipeline with the
241+
// measure's own id, so that is what the derived schedule has to name for the schedule and the
242+
// report types to agree. Monthly matches the frequency the non-DMRP path posts.
243+
var created = await dmrpClient.CreateMeasureMappingAsync(new MeasureMappingModel
244+
{
245+
Measure = measureId,
246+
DQM = measureId,
247+
Frequency = Frequency.Monthly
248+
}, cancellationToken);
249+
250+
if (created.IsSuccessStatusCode && !string.IsNullOrWhiteSpace(created.Body?.Id))
251+
{
252+
return created.Body!.Id!;
253+
}
254+
255+
// Mappings are shared by every run against a stack, so a run starting alongside another can
256+
// lose the race to create one. Losing it is not a failure — the mapping it needed now exists.
257+
if (created.StatusCode == (int)HttpStatusCode.BadRequest)
258+
{
259+
existingId = await FindMeasureMappingAsync(dmrpClient, measureId, cancellationToken);
260+
if (existingId != null)
261+
{
262+
output.WriteLine($"DMRP measure mapping for '{measureId}' was created concurrently. Reusing it.");
263+
return existingId;
264+
}
265+
}
266+
267+
throw new InvalidOperationException(
268+
$"Failed to create DMRP measure mapping for measure '{measureId}'. HTTP {created.StatusCode}: {created.RawBody ?? "(no body)"}");
269+
}
270+
271+
private static async Task<string?> FindMeasureMappingAsync(
272+
IDmrpServiceClient dmrpClient,
273+
string measureId,
274+
CancellationToken cancellationToken)
275+
{
276+
// Both filters match exactly, and measure and dQM together are unique, so this is at most one
277+
// row. Nothing matching answers 204 with no body rather than an empty page.
278+
var search = await dmrpClient.SearchMeasureMappingsAsync(measure: measureId, dqm: measureId,
279+
pageSize: 1, pageNumber: 1, cancellationToken: cancellationToken);
280+
281+
return search.Body?.Records?.FirstOrDefault()?.Id;
282+
}
283+
284+
private static async Task EnsureReportingPlanAsync(
285+
IDmrpServiceClient dmrpClient,
286+
IAutomationOutput output,
287+
string facilityId,
288+
string measureMappingId,
289+
int month,
290+
int year,
291+
CancellationToken cancellationToken)
292+
{
293+
var created = await dmrpClient.CreateFacilityReportingPlanAsync(new FacilityReportingPlanRequest
294+
{
295+
FacilityId = facilityId,
296+
MeasureMappingId = measureMappingId,
297+
ReportingMonth = month,
298+
ReportingYear = year,
299+
IsReporting = true
300+
}, cancellationToken);
301+
302+
if (created.IsSuccessStatusCode)
303+
{
304+
return;
305+
}
306+
307+
if (created.StatusCode == (int)HttpStatusCode.Conflict)
308+
{
309+
output.WriteLine($"DMRP reporting plan for facility '{facilityId}' and {month}/{year} already exists. Skipping create.");
310+
return;
311+
}
312+
313+
throw new InvalidOperationException(
314+
$"Failed to create DMRP reporting plan for facility '{facilityId}' ({month}/{year}). HTTP {created.StatusCode}: {created.RawBody ?? "(no body)"}");
64315
}
65316

66317
private static async Task WaitForFacilityReadConsistencyAsync(

DotNet/Automation.UI/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ static IReadOnlyCollection<string> BuildValidAudiences(string configuredAudience
251251
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.QueryDispatchTestSuite>();
252252
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.SubmissionServiceTestSuite>();
253253
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.MeasureEvalTestSuite>();
254+
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.DmrpTestSuite>();
254255
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.ValidationServiceTestSuite>();
255256
builder.Services.AddSingleton<Automation.UI.Services.ApiHealth.TestSuites.IServiceTestSuite, Automation.UI.Services.ApiHealth.TestSuites.AdminBffTestSuite>();
256257

0 commit comments

Comments
 (0)