From c9c32656b25f8ba100f28db865ef283ff68f2ab0 Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Tue, 28 Jul 2026 10:02:49 -0400 Subject: [PATCH 1/2] feat(flow4d): bounded pathway-serving machinery (build; activation gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reusable machinery to serve REAL (non-overlay) governed-pathway progress in the 4D Navigator for the Nightingale investor-demo cohort (PR #118). Build only — no prod writes; catalog activation + assignment remain gated behind [SU] signoff. - Flow4dPathwayAssignmentService: layers a pathway_instance + LOS-driven milestone statuses onto an existing #118 cohort grant (source_encounter_id + pathway:read); requires the catalog already activated (instantiate enforces approved+active + release signed-off), so it fails loudly otherwise. - care-pathways:promote-executable-layer: the missing draft→approved milestone/stage promotion (scope REQUIRED, --confirm REQUIRED; never promotes the whole catalog). review_state is not a protected content column, so the immutability trigger allows it. - flow4d:assign-demo-pathways: reads the cohort SSOT (NightingaleDemoCohort::MEMBERS demo_username→pathway_key) + its grants and assigns each patient their pathway. - Flow4dPathwayServingTest: proves the full loop end-to-end in the isolated test DB — adopt → author(draft) → promote → activate → cohort grant → assign → serve real progress (PathwayInstanceReadService, demo=false). This IS the prod runbook, proven. Scoped to the 5 cohort pathways (all evidence-verified). 5 tests green; Pint clean. Note: PatientJourneyEndpointTest event-count assertion is a pre-existing LOCAL-only failure from #111's identity changes (main CI is green) — unrelated to this branch. --- ...ePathwaysPromoteExecutableLayerCommand.php | 80 ++++++ .../Flow4dAssignDemoPathwaysCommand.php | 102 +++++++ .../Flow4dPathwayAssignmentService.php | 118 ++++++++ .../CarePathways/Flow4dPathwayServingTest.php | 262 ++++++++++++++++++ 4 files changed, 562 insertions(+) create mode 100644 app/Console/Commands/CarePathwaysPromoteExecutableLayerCommand.php create mode 100644 app/Console/Commands/Flow4dAssignDemoPathwaysCommand.php create mode 100644 app/Services/CarePathways/Flow4dPathwayAssignmentService.php create mode 100644 tests/Feature/CarePathways/Flow4dPathwayServingTest.php diff --git a/app/Console/Commands/CarePathwaysPromoteExecutableLayerCommand.php b/app/Console/Commands/CarePathwaysPromoteExecutableLayerCommand.php new file mode 100644 index 00000000..e6bd4d40 --- /dev/null +++ b/app/Console/Commands/CarePathwaysPromoteExecutableLayerCommand.php @@ -0,0 +1,80 @@ +option('version-id')))); + $pathwayKeys = array_values(array_filter(array_map('trim', (array) $this->option('pathway-key')))); + + if ($versionIds === [] && $pathwayKeys === []) { + $this->error('Scope is required: pass at least one --version-id or --pathway-key. This command never promotes the whole catalog.'); + + return self::INVALID; + } + + $targets = DB::table('care_pathways.versions as v') + ->join('care_pathways.definitions as d', 'd.pathway_definition_id', '=', 'v.pathway_definition_id') + ->when($versionIds !== [], fn ($q) => $q->whereIn('v.pathway_version_id', $versionIds)) + ->when($pathwayKeys !== [], fn ($q) => $q->orWhereIn('d.pathway_key', $pathwayKeys)) + ->pluck('v.pathway_version_id') + ->map(fn ($id): int => (int) $id) + ->unique() + ->values(); + + if ($targets->isEmpty()) { + $this->warn('No matching versions found for the given scope.'); + + return self::SUCCESS; + } + + $draftMilestones = DB::table('care_pathways.milestone_definitions') + ->whereIn('pathway_version_id', $targets)->where('review_state', 'draft')->count(); + $draftStages = DB::table('care_pathways.stage_definitions') + ->whereIn('pathway_version_id', $targets)->where('review_state', 'draft')->count(); + + $this->line(sprintf('Scope: %d version(s). Drafts to promote: %d milestones, %d stages.', $targets->count(), $draftMilestones, $draftStages)); + + if ($this->option('dry-run')) { + $this->info('Dry run — nothing written.'); + + return self::SUCCESS; + } + + if ($this->option('confirm') !== self::CONFIRM) { + $this->error('Refusing to write. Re-run with --confirm='.self::CONFIRM.' (records your promotion of these drafts to approved).'); + + return self::INVALID; + } + + [$promotedMilestones, $promotedStages] = DB::transaction(function () use ($targets): array { + $m = DB::table('care_pathways.milestone_definitions') + ->whereIn('pathway_version_id', $targets)->where('review_state', 'draft') + ->update(['review_state' => 'approved', 'updated_at' => now()]); + $s = DB::table('care_pathways.stage_definitions') + ->whereIn('pathway_version_id', $targets)->where('review_state', 'draft') + ->update(['review_state' => 'approved', 'updated_at' => now()]); + + return [$m, $s]; + }); + + $this->info(sprintf('Promoted %d milestone(s) and %d stage(s) to approved across %d version(s).', $promotedMilestones, $promotedStages, $targets->count())); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Flow4dAssignDemoPathwaysCommand.php b/app/Console/Commands/Flow4dAssignDemoPathwaysCommand.php new file mode 100644 index 00000000..3a0720c4 --- /dev/null +++ b/app/Console/Commands/Flow4dAssignDemoPathwaysCommand.php @@ -0,0 +1,102 @@ +where('source_system_key', NightingaleDemoCohort::SOURCE_SYSTEM_KEY) + ->get(); + + if ($grants->isEmpty()) { + $this->warn('No Nightingale demo-cohort grants found. Provision the cohort first (NIGHTINGALE_DEMO_PROVISIONING_ENABLED + its command).'); + + return self::SUCCESS; + } + + $now = CarbonImmutable::now(); + $plan = []; + foreach ($grants as $grant) { + $metadata = is_array($grant->metadata) ? $grant->metadata : []; + $alias = is_string($metadata['demo_username'] ?? null) ? $metadata['demo_username'] : null; + $member = $alias !== null ? (NightingaleDemoCohort::MEMBERS[$alias] ?? null) : null; + if ($member === null || $grant->source_encounter_id === null) { + continue; + } + $plan[] = [ + 'alias' => $alias, + 'encounter_id' => (int) $grant->source_encounter_id, + 'pathway_key' => (string) $member['pathway_key'], + ]; + } + + if ($plan === []) { + $this->warn('Cohort grants found, but none carried a resolvable demo_username + encounter.'); + + return self::SUCCESS; + } + + $this->table( + ['Patient', 'Encounter', 'Pathway'], + array_map(static fn (array $p): array => [$p['alias'], (string) $p['encounter_id'], $p['pathway_key']], $plan), + ); + + if ($this->option('dry-run')) { + $this->info('Dry run — nothing written.'); + + return self::SUCCESS; + } + + if ($this->option('confirm') !== self::CONFIRM) { + $this->error('Refusing to write. Re-run with --confirm='.self::CONFIRM.' once the catalog is activated for these pathways.'); + + return self::INVALID; + } + + $assigned = 0; + foreach ($plan as $entry) { + $encounter = Encounter::query()->find($entry['encounter_id']); + $admittedAt = $encounter?->admitted_at + ? CarbonImmutable::parse((string) $encounter->admitted_at) + : $now->subDays(2); + + try { + $summary = $assigner->assignByPathwayKey($entry['encounter_id'], $entry['pathway_key'], $admittedAt, $now); + if ($summary === null) { + $this->warn(sprintf(' %s: no effective pathway:read grant — skipped', $entry['alias'])); + + continue; + } + $this->line(sprintf( + ' %s → %s: %d milestones (%d complete, %d current)', + $entry['alias'], $entry['pathway_key'], $summary['milestones'], $summary['completed'], $summary['current'], + )); + $assigned++; + } catch (RuntimeException $exception) { + $this->error(sprintf(' %s: %s', $entry['alias'], $exception->getMessage())); + } + } + + $this->info(sprintf('Assigned %d cohort pathway(s).', $assigned)); + + return self::SUCCESS; + } +} diff --git a/app/Services/CarePathways/Flow4dPathwayAssignmentService.php b/app/Services/CarePathways/Flow4dPathwayAssignmentService.php new file mode 100644 index 00000000..b002b2a7 --- /dev/null +++ b/app/Services/CarePathways/Flow4dPathwayAssignmentService.php @@ -0,0 +1,118 @@ +|null null when the encounter has no effective + * pathway:read grant (nothing to assign) + */ + public function assignByPathwayKey( + int $sourceEncounterId, + string $pathwayKey, + CarbonImmutable $admittedAt, + CarbonImmutable $now, + ): ?array { + $grant = PatientEncounterAccessGrant::query() + ->effective() + ->where('source_encounter_id', $sourceEncounterId) + ->orderByDesc('access_grant_id') + ->first(); + + if (! $grant instanceof PatientEncounterAccessGrant || ! $grant->permits('pathway:read')) { + return null; + } + + $version = PathwayVersion::query() + ->whereHas('definition', fn ($query) => $query->where('pathway_key', $pathwayKey)) + ->where('activation_status', 'active') + ->where('institutional_approval_status', 'approved') + ->first(); + + if (! $version instanceof PathwayVersion) { + throw new RuntimeException("no active, approved version for pathway '{$pathwayKey}' — activate the catalog first"); + } + + $reference = 'flow4d-demo/'.$sourceEncounterId.'/'.$pathwayKey; + $instance = $this->instances->instantiate($grant, $version, 'flow4d-demo-assignment', $reference, $now); + + // LOS days; cast the Carbon 3 float BEFORE intdiv (no lossy implicit cast). + $losDays = intdiv((int) max(0.0, $admittedAt->diffInMinutes($now, false)), 1440); + + $milestones = MilestoneDefinition::query() + ->where('pathway_version_id', $version->getKey()) + ->where('review_state', 'approved') + ->orderBy('sequence') + ->orderBy('stable_key') + ->get(); + + $counts = ['completed' => 0, 'current' => 0, 'planned' => 0]; + foreach ($milestones as $milestone) { + $status = $this->statusForMilestone($milestone, $losDays); + $this->instances->recordMilestoneStatus( + $instance, + $milestone, + $status, + $reference.'/'.$milestone->stable_key, + $now, + ); + $counts[$status]++; + } + + return [ + 'encounter_id' => $sourceEncounterId, + 'pathway_key' => $pathwayKey, + 'version_id' => (int) $version->getKey(), + 'instance_id' => (int) $instance->getKey(), + 'los_days' => $losDays, + 'milestones' => $milestones->count(), + ] + $counts; + } + + private function statusForMilestone(MilestoneDefinition $milestone, int $losDays): string + { + $range = is_array($milestone->expected_range) ? $milestone->expected_range : []; + $day = null; + if (isset($range['day_offset_max']) && is_numeric($range['day_offset_max'])) { + $day = (int) $range['day_offset_max']; + } elseif (isset($range['day_offset_min']) && is_numeric($range['day_offset_min'])) { + $day = (int) $range['day_offset_min']; + } + + return match (true) { + $day === null => 'planned', + $day < $losDays => 'completed', + $day === $losDays => 'current', + default => 'planned', + }; + } +} diff --git a/tests/Feature/CarePathways/Flow4dPathwayServingTest.php b/tests/Feature/CarePathways/Flow4dPathwayServingTest.php new file mode 100644 index 00000000..d5f160c8 --- /dev/null +++ b/tests/Feature/CarePathways/Flow4dPathwayServingTest.php @@ -0,0 +1,262 @@ +configureCarePathwayFixture(); + $this->seedCarePathwayRawFixture(); + app(CatalogImportService::class)->adopt(1, 'test-data-steward'); + + $version = PathwayVersion::query()->with('definition')->orderBy('source_rank')->firstOrFail(); + $pathwayKey = (string) $version->definition->pathway_key; + + // 2. Author executable layer — seed DRAFT milestones with day offsets. + $this->seedDraftMilestones((int) $version->getKey()); + $this->assertSame(3, MilestoneDefinition::query()->where('review_state', 'draft')->count()); + + // 3. Promote drafts → approved via the command (scoped to this version). + $this->artisan('care-pathways:promote-executable-layer', [ + '--version-id' => [(int) $version->getKey()], + '--confirm' => 'promote-drafts-to-approved', + ])->assertExitCode(0); + $this->assertSame(0, MilestoneDefinition::query()->where('review_state', 'draft')->count()); + $this->assertSame(3, MilestoneDefinition::query()->where('review_state', 'approved')->count()); + + // 4. Record institutional signoff — approve + activate every version, then + // the release (the trigger gate requires all-approved + count match). + $this->activateFixtureRelease(); + + // 5. Cohort grant (PR #118 shape): effective, pathway:read, linked to the + // flow encounter via source_encounter_id. + $grant = app(SyntheticPatientProjectionProvisioner::class)->provision('flow4d-serving')['grant']; + DB::table('patient_experience.encounter_access_grants') + ->where('access_grant_id', $grant->getKey()) + ->update(['source_encounter_id' => self::ENCOUNTER_ID]); + + // 6. Assign the pathway + LOS-driven milestone statuses. + $now = CarbonImmutable::parse('2026-07-28T12:00:00Z'); + $admittedAt = $now->subDays(2)->subHours(5); // LOS = 2 days + $summary = app(Flow4dPathwayAssignmentService::class) + ->assignByPathwayKey(self::ENCOUNTER_ID, $pathwayKey, $admittedAt, $now); + + $this->assertNotNull($summary); + $this->assertSame(2, $summary['los_days']); + $this->assertSame(3, $summary['milestones']); + $this->assertSame(2, $summary['completed']); // day 0 + day 1 + $this->assertSame(1, $summary['current']); // day 2 + $this->assertSame(0, $summary['planned']); + + // 7. Serve — the 4D Navigator read path returns REAL progress (not the + // synthetic demo overlay) for this encounter. + config(['care-pathways.assignment_enabled' => true]); + $progress = app(PathwayInstanceReadService::class)->progressForEncounterId(self::ENCOUNTER_ID); + + $this->assertNotNull($progress); + $this->assertFalse($progress['demo']); + $this->assertFalse($progress['clinical_use']); // still never presented as guidance + $this->assertSame('assigned_instance', $progress['source']); + $this->assertSame( + ['completed', 'completed', 'current'], + array_column($progress['milestones'], 'status'), + ); + $this->assertSame(2, $progress['summary']['completed']); + $this->assertSame('day_2_m01', $progress['summary']['current_stable_key']); + } + + public function test_assign_demo_pathways_command_serves_a_cohort_patient(): void + { + // Activated catalog with one version renamed to a cohort pathway_key. + $this->configureCarePathwayFixture(); + $this->seedCarePathwayRawFixture(); + app(CatalogImportService::class)->adopt(1, 'test-data-steward'); + $version = PathwayVersion::query()->with('definition')->orderBy('source_rank')->firstOrFail(); + $cohortKey = (string) NightingaleDemoCohort::MEMBERS['demo1']['pathway_key']; + DB::table('care_pathways.definitions') + ->where('pathway_definition_id', $version->pathway_definition_id) + ->update(['pathway_key' => $cohortKey]); + $this->seedApprovedMilestones((int) $version->getKey()); + $this->activateFixtureRelease(); + + // A flow encounter + a cohort-shaped grant (demo_username=demo1). + $encounter = Encounter::create([ + 'patient_ref' => 'demo-nightingale-investor-01', + 'admitted_at' => CarbonImmutable::now()->subDays(2), + 'status' => 'active', + 'is_deleted' => false, + ]); + $grant = app(SyntheticPatientProjectionProvisioner::class)->provision('flow4d-cohort')['grant']; + $grant->update([ + 'source_system_key' => NightingaleDemoCohort::SOURCE_SYSTEM_KEY, + 'source_encounter_id' => $encounter->getKey(), + 'metadata' => ['demo_username' => 'demo1', 'synthetic' => true], + ]); + + $this->artisan('flow4d:assign-demo-pathways', ['--confirm' => 'assign-demo-cohort-pathways']) + ->assertExitCode(0); + + $this->assertSame(1, PatientPathwayInstance::query()->where('access_grant_id', $grant->getKey())->count()); + + config(['care-pathways.assignment_enabled' => true]); + $progress = app(PathwayInstanceReadService::class)->progressForEncounterId((int) $encounter->getKey()); + $this->assertNotNull($progress); + $this->assertSame('assigned_instance', $progress['source']); + $this->assertFalse($progress['demo']); + } + + public function test_assignment_needs_an_activated_catalog(): void + { + // Without activation, assign fails loudly — nothing serves by accident. + $this->configureCarePathwayFixture(); + $this->seedCarePathwayRawFixture(); + app(CatalogImportService::class)->adopt(1, 'test-data-steward'); + $version = PathwayVersion::query()->with('definition')->orderBy('source_rank')->firstOrFail(); + + $grant = app(SyntheticPatientProjectionProvisioner::class)->provision('flow4d-serving-inactive')['grant']; + DB::table('patient_experience.encounter_access_grants') + ->where('access_grant_id', $grant->getKey())->update(['source_encounter_id' => self::ENCOUNTER_ID]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('activate the catalog first'); + app(Flow4dPathwayAssignmentService::class)->assignByPathwayKey( + self::ENCOUNTER_ID, + (string) $version->definition->pathway_key, + CarbonImmutable::now()->subDay(), + CarbonImmutable::now(), + ); + } + + public function test_assignment_is_null_without_a_grant(): void + { + $this->configureCarePathwayFixture(); + $this->seedCarePathwayRawFixture(); + app(CatalogImportService::class)->adopt(1, 'test-data-steward'); + $this->activateFixtureRelease(); + $version = PathwayVersion::query()->with('definition')->orderBy('source_rank')->firstOrFail(); + + // No grant for encounter 999999 → nothing to assign. + $this->assertNull(app(Flow4dPathwayAssignmentService::class)->assignByPathwayKey( + 999999, + (string) $version->definition->pathway_key, + CarbonImmutable::now()->subDay(), + CarbonImmutable::now(), + )); + } + + public function test_promote_command_requires_scope_and_confirmation(): void + { + // No scope → refuses (never promotes the whole catalog). + $this->artisan('care-pathways:promote-executable-layer') + ->expectsOutputToContain('Scope is required') + ->assertExitCode(2); + + // Scoped at a real version with drafts, but unconfirmed → refuses to write. + $this->configureCarePathwayFixture(); + $this->seedCarePathwayRawFixture(); + app(CatalogImportService::class)->adopt(1, 'test-data-steward'); + $versionId = (int) PathwayVersion::query()->orderBy('source_rank')->value('pathway_version_id'); + $this->seedDraftMilestones($versionId); + + $this->artisan('care-pathways:promote-executable-layer', ['--version-id' => [$versionId]]) + ->expectsOutputToContain('Refusing to write') + ->assertExitCode(2); + $this->assertSame(3, MilestoneDefinition::query()->where('review_state', 'draft')->count()); + } + + private function seedDraftMilestones(int $versionId): void + { + $this->seedMilestones($versionId, 'draft'); + } + + private function seedApprovedMilestones(int $versionId): void + { + $this->seedMilestones($versionId, 'approved'); + } + + private function seedMilestones(int $versionId, string $reviewState): void + { + $rows = [ + ['stable_key' => 'arrival_m00', 'title' => 'Admitted and settled', 'phase' => 'arrival', 'sequence' => 0, 'expected_range' => ['day_offset_min' => 0, 'day_offset_max' => 0]], + ['stable_key' => 'day_1_m01', 'title' => 'Initial workup complete', 'phase' => 'day_1', 'sequence' => 1, 'expected_range' => ['day_offset_min' => 0, 'day_offset_max' => 1]], + ['stable_key' => 'day_2_m01', 'title' => 'Repeat imaging reviewed', 'phase' => 'day_2', 'sequence' => 2, 'expected_range' => ['day_offset_min' => 1, 'day_offset_max' => 2]], + ]; + foreach ($rows as $row) { + MilestoneDefinition::query()->create([ + 'pathway_version_id' => $versionId, + 'milestone_uuid' => (string) Str::uuid(), + 'stable_key' => $row['stable_key'], + 'title' => $row['title'], + 'phase' => $row['phase'], + 'sequence' => $row['sequence'], + 'predecessor_keys' => [], + 'expected_range' => $row['expected_range'], + 'review_state' => $reviewState, + ]); + } + } + + private function activateFixtureRelease(): void + { + $summary = app(CatalogImportService::class)->adopt(1, 'test-data-steward'); + + DB::table('care_pathways.definitions')->update(['lifecycle_state' => 'active', 'updated_at' => now()]); + DB::table('care_pathways.versions')->update([ + 'institutional_approval_status' => 'approved', + 'activation_status' => 'active', + 'updated_at' => now(), + ]); + $pathwayCount = (int) DB::table('care_pathways.versions')->count(); + DB::table('care_pathways.catalog_releases') + ->where('catalog_release_id', $summary['catalog_release_id']) + ->update([ + 'state' => 'active', + 'clinical_signoff_complete' => true, + 'clinical_signoff_count' => $pathwayCount, + 'activated_by_user_id' => 999, + 'activated_at' => now(), + 'updated_at' => now(), + ]); + } +} From 222534b488c67c507be7000316eb6835c87c7e0a Mon Sep 17 00:00:00 2001 From: Sanjay Udoshi Date: Tue, 28 Jul 2026 10:51:16 -0400 Subject: [PATCH 2/2] fix(test): anchor PatientJourneyEndpointTest events to now (calendar bit-rot) The journey seeds hardcoded 2026-07-25 events, but the journey window is now-72h; once the calendar advanced >72h past those dates the events aged out of range and the ordered-story assertion dropped from 3 to 1. Anchors the seed to now (gaps preserved: 210 + 525 min) so it's stable. Unrelated to the serving machinery; unblocks feature-1 CI (and fixes the same latent failure on main). --- .../PatientFlow/PatientJourneyEndpointTest.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/Feature/PatientFlow/PatientJourneyEndpointTest.php b/tests/Feature/PatientFlow/PatientJourneyEndpointTest.php index a14d28d4..b69a09ae 100644 --- a/tests/Feature/PatientFlow/PatientJourneyEndpointTest.php +++ b/tests/Feature/PatientFlow/PatientJourneyEndpointTest.php @@ -43,14 +43,19 @@ private function admin(): User /** Admit → transfer → discharge for one synthetic patient, via the real normalizer. */ private function seedJourneyEvents(): string { + // Anchor to `now` so the events always fall inside the journey's 72h + // lookback window. (Previously hardcoded 2026-07-25 dates that bit-rotted + // out of range once the calendar advanced >72h past them.) Gaps preserved: + // A01→A02 = 210 min, A02→A03 = 525 min. + $base = CarbonImmutable::now()->subHours(30); $messages = [ - ['A01', '2026-07-25 08:00:00', 'TICU^TICU-R001^TICU-B001^ZEPHYRUS'], - ['A02', '2026-07-25 11:30:00', 'MS5B^MS5B-R001^MS5B-B001^ZEPHYRUS'], - ['A03', '2026-07-25 20:15:00', 'MS5B^MS5B-R001^MS5B-B001^ZEPHYRUS'], + ['A01', $base, 'TICU^TICU-R001^TICU-B001^ZEPHYRUS'], + ['A02', $base->addMinutes(210), 'MS5B^MS5B-R001^MS5B-B001^ZEPHYRUS'], + ['A03', $base->addMinutes(210 + 525), 'MS5B^MS5B-R001^MS5B-B001^ZEPHYRUS'], ]; foreach ($messages as $index => [$trigger, $time, $location]) { - $hl7Time = CarbonImmutable::parse($time)->format('YmdHis'); + $hl7Time = $time->format('YmdHis'); $raw = implode("\r", [ "MSH|^~\\&|EHR|AMC|FLOW|AMC|{$hl7Time}||ADT^{$trigger}|JRNMSG{$index}|P|2.5.1", "EVN|{$trigger}|{$hl7Time}",