Files
ladill-care/app/Services/Care/PathwayService.php
T
isaacclad 2ce4bc8993
Deploy Ladill Care / deploy (push) Successful in 1m26s
feat(assessments): layered clinical assessment engine end-to-end
Add a template-driven assessment system with universal intake, clinical
pathways, disease instruments (stroke MVP + extended, diabetes, and ten
specialty packs), scoring, patient outcome trends, REST/API + FHIR
export, and Enterprise org-level assessment analytics. Seed packs and
design/licensing docs ship for deploy and pre-GA review.
2026-07-16 22:58:09 +00:00

200 lines
6.8 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\ClinicalPathway;
use App\Models\Consultation;
use App\Models\Member;
use App\Models\Patient;
use App\Models\PatientPathway;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class PathwayService
{
public function __construct(
protected PathwayMatcher $matcher,
protected AssessmentService $assessments,
protected CarePermissions $permissions,
) {}
/**
* @param iterable<\App\Models\Diagnosis|array{code?: string, description?: string}> $diagnoses
* @return Collection<int, array{pathway: ClinicalPathway, pathway_code: string, pathway_name: string, rank: int, match_reason: string, already_active: bool}>
*/
public function suggest(Patient $patient, iterable $diagnoses): Collection
{
$activeIds = $this->activeFor($patient)->pluck('pathway_id')->all();
return $this->matcher->match($diagnoses)->map(function (array $row) use ($activeIds) {
$row['already_active'] = in_array($row['pathway']->id, $activeIds, true);
return $row;
});
}
/**
* @param array{
* consultation?: ?Consultation,
* activation_diagnosis_text?: ?string,
* actor?: ?string,
* notes?: ?string,
* } $context
*/
public function activate(
Patient $patient,
ClinicalPathway $pathway,
string $ownerRef,
?Member $member,
array $context = [],
): PatientPathway {
if (! $this->permissions->can($member, 'pathways.manage')) {
throw new HttpException(403, 'Not authorized to manage clinical pathways.');
}
if (! $pathway->is_active) {
throw ValidationException::withMessages([
'pathway_code' => 'Pathway is inactive.',
]);
}
$consultation = $context['consultation'] ?? null;
$actor = $context['actor'] ?? null;
$diagnosisText = $context['activation_diagnosis_text']
?? $this->snapshotDiagnosisText($consultation);
return DB::transaction(function () use ($patient, $pathway, $ownerRef, $member, $consultation, $actor, $diagnosisText, $context) {
$existingActive = PatientPathway::query()
->where('patient_id', $patient->id)
->where('pathway_id', $pathway->id)
->where('status', PatientPathway::STATUS_ACTIVE)
->lockForUpdate()
->first();
if ($existingActive) {
return $existingActive->load(['pathway.templates', 'assessments.template']);
}
$patientPathway = PatientPathway::create([
'owner_ref' => $ownerRef,
'organization_id' => $patient->organization_id,
'patient_id' => $patient->id,
'pathway_id' => $pathway->id,
'status' => PatientPathway::STATUS_ACTIVE,
'activated_at' => now(),
'activated_by' => $actor,
'activation_consultation_id' => $consultation?->id,
'activation_diagnosis_text' => $diagnosisText ? mb_substr($diagnosisText, 0, 500) : null,
'notes' => $context['notes'] ?? null,
]);
$pathway->loadMissing('templates');
foreach ($pathway->templates->where('is_required_on_activation', true) as $binding) {
try {
$this->assessments->start(
$patient,
$binding->template_code,
$ownerRef,
$member,
[
'consultation' => $consultation,
'visit' => $consultation?->visit,
'patient_pathway' => $patientPathway,
'actor' => $actor,
],
);
} catch (ValidationException $e) {
// Template may not be seeded yet (content pack PR); skip missing packs.
if (isset($e->errors()['template_code'])) {
continue;
}
throw $e;
}
}
AuditLogger::record(
$ownerRef,
'pathway.activated',
$patient->organization_id,
$actor,
PatientPathway::class,
$patientPathway->id,
[
'pathway_code' => $pathway->code,
'activation_diagnosis_text' => $patientPathway->activation_diagnosis_text,
],
);
return $patientPathway->load(['pathway.templates', 'assessments.template']);
});
}
public function deactivate(
PatientPathway $patientPathway,
string $ownerRef,
?Member $member,
?string $actorRef = null,
): PatientPathway {
if (! $this->permissions->can($member, 'pathways.manage')) {
throw new HttpException(403, 'Not authorized to manage clinical pathways.');
}
if ($patientPathway->owner_ref !== $ownerRef) {
throw new HttpException(404, 'Pathway enrollment not found.');
}
if ($patientPathway->status !== PatientPathway::STATUS_ACTIVE) {
return $patientPathway;
}
$patientPathway->update([
'status' => PatientPathway::STATUS_INACTIVE,
'resolved_at' => now(),
]);
AuditLogger::record(
$ownerRef,
'pathway.deactivated',
$patientPathway->organization_id,
$actorRef,
PatientPathway::class,
$patientPathway->id,
['pathway_code' => $patientPathway->pathway?->code ?? $patientPathway->pathway_id],
);
return $patientPathway->fresh(['pathway']);
}
/**
* @return Collection<int, PatientPathway>
*/
public function activeFor(Patient $patient): Collection
{
return PatientPathway::query()
->where('patient_id', $patient->id)
->where('status', PatientPathway::STATUS_ACTIVE)
->with('pathway')
->orderByDesc('activated_at')
->get();
}
protected function snapshotDiagnosisText(?Consultation $consultation): ?string
{
if (! $consultation) {
return null;
}
$consultation->loadMissing('diagnoses');
if ($consultation->diagnoses->isEmpty()) {
return null;
}
return $consultation->diagnoses
->map(fn ($d) => trim(($d->code ? $d->code.' ' : '').$d->description))
->filter()
->implode('; ');
}
}