feat(assessments): layered clinical assessment engine end-to-end
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:
isaacclad
2026-07-16 22:58:09 +00:00
parent 8896088425
commit 2ce4bc8993
94 changed files with 13430 additions and 56 deletions
@@ -7,8 +7,13 @@ use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Models\Consultation;
use App\Models\InvestigationType;
use App\Models\Practitioner;
use App\Models\Assessment;
use App\Models\AssessmentTemplate;
use App\Services\Care\CareFeatures;
use App\Services\Care\CarePermissions;
use App\Services\Care\ConsultationService;
use App\Services\Care\OrganizationResolver;
use App\Services\Care\PathwayService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -32,25 +37,127 @@ class ConsultationController extends Controller
'investigationRequests.investigationType', 'prescriptions.items',
]);
$canManage = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'consultations.manage');
$canVitals = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'vitals.manage');
$canRequestInvestigations = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'investigations.request');
$canPrescribe = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'prescriptions.manage');
$canGenerateBill = app(\App\Services\Care\CarePermissions::class)
->can($this->member($request), 'bills.manage');
$permissions = app(CarePermissions::class);
$member = $this->member($request);
$organization = $this->organization($request);
$canManage = $permissions->can($member, 'consultations.manage');
$canVitals = $permissions->can($member, 'vitals.manage');
$canRequestInvestigations = $permissions->can($member, 'investigations.request');
$canPrescribe = $permissions->can($member, 'prescriptions.manage');
$canGenerateBill = $permissions->can($member, 'bills.manage');
$assessmentsEnabled = app(CareFeatures::class)->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE);
$canViewAssessments = $assessmentsEnabled && $permissions->can($member, 'assessments.view');
$canCaptureAssessment = $assessmentsEnabled && (
$permissions->can($member, 'assessments.capture')
|| $permissions->can($member, 'assessments.manage')
);
$consultationAssessments = collect();
$startableTemplates = collect();
$universalTemplate = null;
$universalAssessment = null;
$canCaptureUniversal = false;
$canManagePathways = $assessmentsEnabled && $permissions->can($member, 'pathways.manage');
$pathwaySuggestions = collect();
$activePathways = collect();
if ($canViewAssessments) {
$consultationAssessments = Assessment::query()
->owned($this->ownerRef($request))
->where('consultation_id', $consultation->id)
->with(['template', 'answers.question'])
->orderByDesc('created_at')
->get();
$universalTemplate = AssessmentTemplate::currentSystemByCode('universal_intake');
if ($universalTemplate) {
$universalAssessment = $consultationAssessments
->first(fn (Assessment $a) => $a->template_id === $universalTemplate->id)
?? Assessment::query()
->owned($this->ownerRef($request))
->where('patient_id', $consultation->patient_id)
->where('template_id', $universalTemplate->id)
->whereIn('status', [Assessment::STATUS_DRAFT, Assessment::STATUS_COMPLETED])
->with(['template', 'answers.question'])
->orderByDesc('created_at')
->first();
}
if ($canCaptureAssessment) {
$startableTemplates = AssessmentTemplate::query()
->system()
->current()
->orderBy('category')
->orderBy('name')
->get()
// Universal has a dedicated CTA; keep other templates in the general list.
->reject(fn (AssessmentTemplate $t) => $t->code === 'universal_intake')
->values();
if ($universalTemplate) {
try {
app(\App\Services\Care\AssessmentService::class)
->assertCaptureAllowed($member, $universalTemplate);
$canCaptureUniversal = true;
} catch (\Throwable) {
$canCaptureUniversal = false;
}
}
}
$pathwayService = app(PathwayService::class);
$activePathways = $pathwayService->activeFor($consultation->patient)
->load(['pathway.templates', 'assessments.template', 'assessments.score']);
// Suggestions from **persisted** diagnoses only (after save).
$pathwaySuggestions = $pathwayService->suggest(
$consultation->patient,
$consultation->diagnoses,
);
// Instruments for active pathways (e.g. stroke → NIHSS, mRS) with draft/complete status.
$pathwayInstruments = collect();
foreach ($activePathways as $patientPathway) {
foreach ($patientPathway->pathway->templates as $binding) {
$template = AssessmentTemplate::currentSystemByCode($binding->template_code);
$assessment = $patientPathway->assessments
->first(fn (Assessment $a) => $a->template?->code === $binding->template_code)
?? $consultationAssessments
->first(fn (Assessment $a) => $a->template?->code === $binding->template_code)
?? ($template
? Assessment::query()
->owned($this->ownerRef($request))
->where('patient_id', $consultation->patient_id)
->where('template_id', $template->id)
->with(['template', 'score'])
->orderByDesc('created_at')
->first()
: null);
$pathwayInstruments->push([
'pathway_code' => $patientPathway->pathway->code,
'pathway_name' => $patientPathway->pathway->name,
'template_code' => $binding->template_code,
'template_name' => $template?->name ?? $binding->template_code,
'required' => $binding->is_required_on_activation,
'phase' => $binding->phase,
'template' => $template,
'assessment' => $assessment,
'pack_missing' => $template === null,
]);
}
}
}
$practitioners = Practitioner::owned($this->ownerRef($request))
->where('organization_id', $this->organization($request)->id)
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
$investigationTypes = InvestigationType::owned($this->ownerRef($request))
->where('organization_id', $this->organization($request)->id)
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
@@ -65,6 +172,19 @@ class ConsultationController extends Controller
'canPrescribe' => $canPrescribe,
'canGenerateBill' => $canGenerateBill,
'isCompleted' => $consultation->status === Consultation::STATUS_COMPLETED,
'assessmentsEnabled' => $assessmentsEnabled,
'canViewAssessments' => $canViewAssessments,
'canCaptureAssessment' => $canCaptureAssessment,
'consultationAssessments' => $consultationAssessments,
'startableTemplates' => $startableTemplates,
'assessmentStatuses' => config('care.assessment_statuses'),
'universalTemplate' => $universalTemplate,
'universalAssessment' => $universalAssessment,
'canCaptureUniversal' => $canCaptureUniversal,
'canManagePathways' => $canManagePathways,
'pathwaySuggestions' => $pathwaySuggestions,
'activePathways' => $activePathways,
'pathwayInstruments' => $pathwayInstruments ?? collect(),
]);
}
@@ -88,7 +208,16 @@ class ConsultationController extends Controller
$this->ownerRef($request),
);
return back()->with('success', 'Consultation saved.');
$message = 'Consultation saved.';
if ($canManage && $request->boolean('suggest_pathways')) {
$consultation->load('diagnoses');
$count = $consultation->diagnoses->filter(fn ($d) => filled($d->description) || filled($d->code))->count();
$message = $count > 0
? 'Diagnoses saved — review pathway suggestions below.'
: 'Consultation saved. Add and save diagnoses to see pathway suggestions.';
}
return back()->with('success', $message);
}
public function complete(Request $request, Consultation $consultation): RedirectResponse