feat(assessments): layered clinical assessment engine end-to-end
Deploy Ladill Care / deploy (push) Successful in 1m26s
Deploy Ladill Care / deploy (push) Successful in 1m26s
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.
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
<?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('; ');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user