Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
80 changes: 80 additions & 0 deletions app/Console/Commands/CarePathwaysPromoteExecutableLayerCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class CarePathwaysPromoteExecutableLayerCommand extends Command
{
protected $signature = 'care-pathways:promote-executable-layer
{--version-id=* : Limit to these pathway_version_id values}
{--pathway-key=* : Limit to these definition pathway_key values}
{--confirm= : Must equal promote-drafts-to-approved to write}
{--dry-run : Report what would be promoted without writing}';

protected $description = 'Promote drafted care-pathway stage/milestone definitions (review_state draft→approved) for named versions — the reviewed step that makes a version assignable. Scope is REQUIRED; there is no promote-everything.';

private const CONFIRM = 'promote-drafts-to-approved';

public function handle(): int
{
$versionIds = array_values(array_unique(array_map('intval', (array) $this->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;
}
}
102 changes: 102 additions & 0 deletions app/Console/Commands/Flow4dAssignDemoPathwaysCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

namespace App\Console\Commands;

use App\Models\Encounter;
use App\Models\Patient\PatientEncounterAccessGrant;
use App\Nightingale\Demo\NightingaleDemoCohort;
use App\Services\CarePathways\Flow4dPathwayAssignmentService;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use RuntimeException;

class Flow4dAssignDemoPathwaysCommand extends Command
{
protected $signature = 'flow4d:assign-demo-pathways
{--confirm= : Must equal assign-demo-cohort-pathways to write}
{--dry-run : List the planned assignments without writing}';

protected $description = 'Assign each Nightingale demo-cohort patient (PR #118) their governed pathway + LOS-based milestone statuses, so the 4D Navigator serves REAL progress. Requires the catalog already activated for those pathways.';

private const CONFIRM = 'assign-demo-cohort-pathways';

public function handle(Flow4dPathwayAssignmentService $assigner): int
{
$grants = PatientEncounterAccessGrant::query()
->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;
}
}
118 changes: 118 additions & 0 deletions app/Services/CarePathways/Flow4dPathwayAssignmentService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);

namespace App\Services\CarePathways;

use App\Models\CarePathways\MilestoneDefinition;
use App\Models\CarePathways\PathwayVersion;
use App\Models\Patient\PatientEncounterAccessGrant;
use App\Services\Patient\Pathway\PatientPathwayInstanceService;
use Carbon\CarbonImmutable;
use RuntimeException;

/**
* Assigns a governed pathway to an encounter that already carries an effective
* `pathway:read` grant (the Nightingale investor-demo cohort, PR #118), then
* records length-of-stay-driven milestone statuses — the missing "assignment"
* layer that lets the 4D Navigator serve real (non-overlay) pathway progress
* for the demo patients.
*
* This is a demonstration provisioner over SYNTHETIC cohort patients. It writes
* the append-only assignment + status history and therefore requires the
* catalog to be ALREADY activated (PatientPathwayInstanceService::instantiate
* enforces version approved+active and release active+signed-off — the gated
* governance step). Serving remains separately gated by
* care-pathways.assignment_enabled + the flow4d flag.
*/
final class Flow4dPathwayAssignmentService
{
public function __construct(private readonly PatientPathwayInstanceService $instances) {}

/**
* Assign the active+approved version of `$pathwayKey` to the encounter's
* effective grant and record LOS-driven milestone statuses.
*
* @return array<string, mixed>|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',
};
}
}
Loading
Loading