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,332 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Assessment;
|
||||
use App\Models\AssessmentTemplate;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\Patient;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Care\AssessmentService;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\FhirAssessmentExporter;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AssessmentController extends Controller
|
||||
{
|
||||
use ScopesApiToAccount;
|
||||
|
||||
public function __construct(
|
||||
protected AssessmentService $assessments,
|
||||
protected CareFeatures $features,
|
||||
protected CarePermissions $permissions,
|
||||
protected FhirAssessmentExporter $fhir,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Patient $patient): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
$page = $this->assessments->listForPatient(
|
||||
$patient,
|
||||
$this->ownerRef($request),
|
||||
$request->only(['status', 'template_code', 'category', 'from', 'to', 'per_page']),
|
||||
$branchScope,
|
||||
);
|
||||
|
||||
return response()->json($page);
|
||||
}
|
||||
|
||||
public function templates(Request $request): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
|
||||
$templates = AssessmentTemplate::query()
|
||||
->system()
|
||||
->current()
|
||||
->with('questions')
|
||||
->orderBy('category')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return response()->json(['data' => $templates]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Patient $patient): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$validated = $request->validate([
|
||||
'template_code' => ['required', 'string', 'max:100'],
|
||||
'consultation_uuid' => ['nullable', 'uuid'],
|
||||
'visit_uuid' => ['nullable', 'uuid'],
|
||||
]);
|
||||
|
||||
$context = $this->resolveContext($request, $patient, $validated);
|
||||
$assessment = $this->assessments->start(
|
||||
$patient,
|
||||
$validated['template_code'],
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
$context,
|
||||
);
|
||||
|
||||
$status = $assessment->wasRecentlyCreated ? 201 : 200;
|
||||
|
||||
return response()->json($this->serializeAssessment($assessment), $status);
|
||||
}
|
||||
|
||||
public function storeForConsultation(Request $request, Consultation $consultation): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeConsultation($request, $consultation);
|
||||
$consultation->loadMissing(['patient', 'visit']);
|
||||
$this->authorizePatient($request, $consultation->patient);
|
||||
|
||||
$validated = $request->validate([
|
||||
'template_code' => ['required', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$assessment = $this->assessments->start(
|
||||
$consultation->patient,
|
||||
$validated['template_code'],
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
[
|
||||
'consultation' => $consultation,
|
||||
'visit' => $consultation->visit,
|
||||
'actor' => $this->ownerRef($request),
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json($this->serializeAssessment($assessment), $assessment->wasRecentlyCreated ? 201 : 200);
|
||||
}
|
||||
|
||||
public function show(Request $request, Assessment $assessment): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
return response()->json($this->serializeAssessment(
|
||||
$assessment->load(['template.questions', 'answers.question', 'score', 'patient', 'consultation', 'visit'])
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request, Assessment $assessment): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
$validated = $request->validate([
|
||||
'answers' => ['nullable', 'array'],
|
||||
'notes' => ['nullable', 'string', 'max:10000'],
|
||||
]);
|
||||
|
||||
$updated = $this->assessments->saveAnswers(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$validated['answers'] ?? [],
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
array_key_exists('notes', $validated) ? $validated['notes'] : null,
|
||||
);
|
||||
|
||||
return response()->json($this->serializeAssessment($updated));
|
||||
}
|
||||
|
||||
public function complete(Request $request, Assessment $assessment): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
if ($request->has('answers') || $request->has('notes')) {
|
||||
$validated = $request->validate([
|
||||
'answers' => ['nullable', 'array'],
|
||||
'notes' => ['nullable', 'string', 'max:10000'],
|
||||
]);
|
||||
$this->assessments->saveAnswers(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$validated['answers'] ?? [],
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
array_key_exists('notes', $validated) ? $validated['notes'] : null,
|
||||
);
|
||||
$assessment->refresh();
|
||||
}
|
||||
|
||||
$completed = $this->assessments->complete(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return response()->json($this->serializeAssessment($completed));
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Assessment $assessment): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
$cancelled = $this->assessments->cancel(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return response()->json($this->serializeAssessment($cancelled));
|
||||
}
|
||||
|
||||
public function fhir(Request $request, Assessment $assessment): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
return response()->json(
|
||||
$this->fhir->exportBundle($assessment),
|
||||
200,
|
||||
['Content-Type' => 'application/fhir+json'],
|
||||
);
|
||||
}
|
||||
|
||||
protected function serializeAssessment(Assessment $assessment): array
|
||||
{
|
||||
$assessment->loadMissing(['template', 'answers.question', 'score', 'patient']);
|
||||
|
||||
$answers = [];
|
||||
foreach ($assessment->answers as $answer) {
|
||||
if ($answer->question) {
|
||||
$answers[$answer->question->code] = $answer->authoritativeValue($answer->question->answer_type);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'uuid' => $assessment->uuid,
|
||||
'status' => $assessment->status,
|
||||
'template_code' => $assessment->template?->code,
|
||||
'template_name' => $assessment->template?->name,
|
||||
'template_version' => $assessment->template?->version,
|
||||
'patient_uuid' => $assessment->patient?->uuid,
|
||||
'consultation_uuid' => $assessment->consultation?->uuid,
|
||||
'visit_uuid' => $assessment->visit?->uuid,
|
||||
'assessed_at' => $assessment->assessed_at?->toIso8601String(),
|
||||
'completed_at' => $assessment->completed_at?->toIso8601String(),
|
||||
'notes' => $assessment->notes,
|
||||
'answers' => $answers,
|
||||
'score' => $assessment->score ? [
|
||||
'total_score' => $assessment->score->total_score,
|
||||
'max_score' => $assessment->score->max_score,
|
||||
'subscores' => $assessment->score->subscores,
|
||||
'severity_label' => $assessment->score->severity_label,
|
||||
'computed_at' => $assessment->score->computed_at?->toIso8601String(),
|
||||
] : null,
|
||||
'template' => $assessment->template,
|
||||
];
|
||||
}
|
||||
|
||||
protected function assertEngineEnabled(Request $request): void
|
||||
{
|
||||
abort_unless(
|
||||
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
protected function authorizeCapture(Request $request): void
|
||||
{
|
||||
$member = $this->member($request);
|
||||
abort_unless(
|
||||
$this->permissions->can($member, 'assessments.capture')
|
||||
|| $this->permissions->can($member, 'assessments.manage'),
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
protected function authorizePatient(Request $request, Patient $patient): void
|
||||
{
|
||||
$this->authorizeOwner($request, $patient);
|
||||
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||
{
|
||||
$this->authorizeOwner($request, $consultation);
|
||||
$consultation->loadMissing('visit');
|
||||
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function authorizeAssessment(Request $request, Assessment $assessment): void
|
||||
{
|
||||
$this->authorizeOwner($request, $assessment);
|
||||
abort_unless($assessment->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $assessment->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{consultation_uuid?: ?string, visit_uuid?: ?string} $validated
|
||||
* @return array{consultation?: Consultation, visit?: Visit, actor: string}
|
||||
*/
|
||||
protected function resolveContext(Request $request, Patient $patient, array $validated): array
|
||||
{
|
||||
$context = ['actor' => $this->ownerRef($request)];
|
||||
|
||||
if (! empty($validated['consultation_uuid'])) {
|
||||
$consultation = Consultation::query()
|
||||
->where('uuid', $validated['consultation_uuid'])
|
||||
->where('patient_id', $patient->id)
|
||||
->firstOrFail();
|
||||
$this->authorizeConsultation($request, $consultation);
|
||||
$consultation->loadMissing('visit');
|
||||
$context['consultation'] = $consultation;
|
||||
$context['visit'] = $consultation->visit;
|
||||
} elseif (! empty($validated['visit_uuid'])) {
|
||||
$visit = Visit::query()
|
||||
->where('uuid', $validated['visit_uuid'])
|
||||
->where('patient_id', $patient->id)
|
||||
->firstOrFail();
|
||||
$this->authorizeOwner($request, $visit);
|
||||
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $visit->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
$context['visit'] = $visit;
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ClinicalPathway;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\Patient;
|
||||
use App\Models\PatientPathway;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use App\Services\Care\PathwayService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PathwayController extends Controller
|
||||
{
|
||||
use ScopesApiToAccount;
|
||||
|
||||
public function __construct(
|
||||
protected PathwayService $pathways,
|
||||
protected CareFeatures $features,
|
||||
protected CarePermissions $permissions,
|
||||
) {}
|
||||
|
||||
public function catalog(Request $request): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
|
||||
$pathways = ClinicalPathway::query()
|
||||
->active()
|
||||
->with('templates')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
return response()->json(['data' => $pathways]);
|
||||
}
|
||||
|
||||
public function index(Request $request, Patient $patient): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$active = $this->pathways->activeFor($patient)->load('pathway.templates');
|
||||
$history = PatientPathway::query()
|
||||
->owned($this->ownerRef($request))
|
||||
->where('patient_id', $patient->id)
|
||||
->with('pathway')
|
||||
->orderByDesc('activated_at')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'active' => $active->map(fn (PatientPathway $pp) => $this->serializePatientPathway($pp)),
|
||||
'history' => $history->map(fn (PatientPathway $pp) => $this->serializePatientPathway($pp)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Patient $patient): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'pathways.manage');
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$validated = $request->validate([
|
||||
'pathway_code' => ['required', 'string', 'max:100'],
|
||||
'consultation_uuid' => ['nullable', 'uuid'],
|
||||
'activation_diagnosis_text' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$pathway = ClinicalPathway::findByCode($validated['pathway_code']);
|
||||
abort_unless($pathway, 422, 'Unknown or inactive pathway.');
|
||||
|
||||
$consultation = null;
|
||||
if (! empty($validated['consultation_uuid'])) {
|
||||
$consultation = Consultation::query()
|
||||
->where('uuid', $validated['consultation_uuid'])
|
||||
->where('patient_id', $patient->id)
|
||||
->firstOrFail();
|
||||
$this->authorizeConsultation($request, $consultation);
|
||||
$consultation->loadMissing(['visit', 'diagnoses']);
|
||||
}
|
||||
|
||||
$patientPathway = $this->pathways->activate(
|
||||
$patient,
|
||||
$pathway,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
[
|
||||
'consultation' => $consultation,
|
||||
'activation_diagnosis_text' => $validated['activation_diagnosis_text'] ?? null,
|
||||
'actor' => $this->ownerRef($request),
|
||||
],
|
||||
);
|
||||
|
||||
return response()->json($this->serializePatientPathway($patientPathway->load(['pathway.templates', 'assessments.template'])), 201);
|
||||
}
|
||||
|
||||
public function deactivate(Request $request, Patient $patient, PatientPathway $patientPathway): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'pathways.manage');
|
||||
$this->authorizePatient($request, $patient);
|
||||
abort_unless($patientPathway->patient_id === $patient->id, 404);
|
||||
$this->authorizeOwner($request, $patientPathway);
|
||||
|
||||
$updated = $this->pathways->deactivate(
|
||||
$patientPathway,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return response()->json($this->serializePatientPathway($updated->load('pathway')));
|
||||
}
|
||||
|
||||
public function suggestions(Request $request, Consultation $consultation): JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'consultations.view');
|
||||
$this->authorizeConsultation($request, $consultation);
|
||||
|
||||
$consultation->loadMissing(['diagnoses', 'patient']);
|
||||
$suggestions = $this->pathways->suggest($consultation->patient, $consultation->diagnoses);
|
||||
|
||||
return response()->json([
|
||||
'data' => $suggestions->map(fn (array $row) => [
|
||||
'pathway_code' => $row['pathway_code'],
|
||||
'pathway_name' => $row['pathway_name'],
|
||||
'rank' => $row['rank'],
|
||||
'match_reason' => $row['match_reason'],
|
||||
'already_active' => $row['already_active'],
|
||||
])->values()->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function serializePatientPathway(PatientPathway $pp): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $pp->uuid,
|
||||
'status' => $pp->status,
|
||||
'pathway_code' => $pp->pathway?->code,
|
||||
'pathway_name' => $pp->pathway?->name,
|
||||
'activated_at' => $pp->activated_at?->toIso8601String(),
|
||||
'activation_diagnosis_text' => $pp->activation_diagnosis_text,
|
||||
'resolved_at' => $pp->resolved_at?->toIso8601String(),
|
||||
'assessments' => $pp->relationLoaded('assessments')
|
||||
? $pp->assessments->map(fn ($a) => [
|
||||
'uuid' => $a->uuid,
|
||||
'status' => $a->status,
|
||||
'template_code' => $a->template?->code,
|
||||
])->values()->all()
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
protected function assertEngineEnabled(Request $request): void
|
||||
{
|
||||
abort_unless(
|
||||
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
protected function authorizePatient(Request $request, Patient $patient): void
|
||||
{
|
||||
$this->authorizeOwner($request, $patient);
|
||||
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||
{
|
||||
$this->authorizeOwner($request, $consultation);
|
||||
$consultation->loadMissing('visit');
|
||||
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Assessment;
|
||||
use App\Models\AssessmentTemplate;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\Patient;
|
||||
use App\Models\Visit;
|
||||
use App\Services\Care\AssessmentService;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\FhirAssessmentExporter;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AssessmentController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(
|
||||
protected AssessmentService $assessments,
|
||||
protected CareFeatures $features,
|
||||
protected CarePermissions $permissions,
|
||||
protected FhirAssessmentExporter $fhir,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Patient $patient): View
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
$assessments = $this->assessments->listForPatient(
|
||||
$patient,
|
||||
$this->ownerRef($request),
|
||||
$request->only(['status', 'template_code', 'category', 'from', 'to', 'per_page']),
|
||||
$branchScope,
|
||||
);
|
||||
|
||||
$canCapture = $this->canCapture($request);
|
||||
|
||||
return view('care.assessments.index', [
|
||||
'patient' => $patient,
|
||||
'assessments' => $assessments,
|
||||
'canCapture' => $canCapture,
|
||||
'statuses' => config('care.assessment_statuses'),
|
||||
'categories' => config('care.assessment_template_categories'),
|
||||
'filters' => $request->only(['status', 'template_code', 'category']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request, Patient $patient): View
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$templates = AssessmentTemplate::query()
|
||||
->system()
|
||||
->current()
|
||||
->orderBy('category')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$member = $this->member($request);
|
||||
$capturable = $templates->filter(function (AssessmentTemplate $template) use ($member) {
|
||||
try {
|
||||
$this->assessments->assertCaptureAllowed($member, $template);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
})->values();
|
||||
|
||||
return view('care.assessments.create', [
|
||||
'patient' => $patient,
|
||||
'templates' => $capturable,
|
||||
'categories' => config('care.assessment_template_categories'),
|
||||
'consultationUuid' => $request->query('consultation_uuid'),
|
||||
'visitUuid' => $request->query('visit_uuid'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Patient $patient): RedirectResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$validated = $request->validate([
|
||||
'template_code' => ['required', 'string', 'max:100'],
|
||||
'consultation_uuid' => ['nullable', 'uuid'],
|
||||
'visit_uuid' => ['nullable', 'uuid'],
|
||||
]);
|
||||
|
||||
$context = $this->resolveContext($request, $patient, $validated);
|
||||
$assessment = $this->assessments->start(
|
||||
$patient,
|
||||
$validated['template_code'],
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
$context,
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('care.assessments.show', $assessment)
|
||||
->with('success', 'Assessment started.');
|
||||
}
|
||||
|
||||
public function storeForConsultation(Request $request, Consultation $consultation): RedirectResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeConsultation($request, $consultation);
|
||||
|
||||
$validated = $request->validate([
|
||||
'template_code' => ['required', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$consultation->loadMissing(['patient', 'visit']);
|
||||
$this->authorizePatient($request, $consultation->patient);
|
||||
|
||||
$assessment = $this->assessments->start(
|
||||
$consultation->patient,
|
||||
$validated['template_code'],
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
[
|
||||
'consultation' => $consultation,
|
||||
'visit' => $consultation->visit,
|
||||
'actor' => $this->ownerRef($request),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('care.assessments.show', $assessment)
|
||||
->with('success', 'Assessment started.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Assessment $assessment): View
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
$assessment->load([
|
||||
'template.questions',
|
||||
'answers.question',
|
||||
'patient',
|
||||
'consultation',
|
||||
'visit',
|
||||
'practitioner',
|
||||
'score',
|
||||
]);
|
||||
|
||||
$answersByCode = $assessment->answers
|
||||
->mapWithKeys(fn ($answer) => [
|
||||
$answer->question->code => $answer->authoritativeValue($answer->question->answer_type),
|
||||
]);
|
||||
|
||||
$canEdit = false;
|
||||
if ($assessment->isDraft() && $this->canCapture($request)) {
|
||||
try {
|
||||
$this->assessments->assertCaptureAllowed($this->member($request), $assessment->template);
|
||||
$canEdit = true;
|
||||
} catch (\Throwable) {
|
||||
$canEdit = false;
|
||||
}
|
||||
}
|
||||
|
||||
return view('care.assessments.show', [
|
||||
'assessment' => $assessment,
|
||||
'answersByCode' => $answersByCode,
|
||||
'canEdit' => $canEdit,
|
||||
'statuses' => config('care.assessment_statuses'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Assessment $assessment): RedirectResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
$validated = $request->validate([
|
||||
'answers' => ['nullable', 'array'],
|
||||
'notes' => ['nullable', 'string', 'max:10000'],
|
||||
]);
|
||||
|
||||
$this->assessments->saveAnswers(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$validated['answers'] ?? [],
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
array_key_exists('notes', $validated) ? $validated['notes'] : null,
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('care.assessments.show', $assessment)
|
||||
->with('success', 'Assessment saved.');
|
||||
}
|
||||
|
||||
public function complete(Request $request, Assessment $assessment): RedirectResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
// Persist any posted answers first (complete form can include them).
|
||||
if ($request->has('answers') || $request->has('notes')) {
|
||||
$validated = $request->validate([
|
||||
'answers' => ['nullable', 'array'],
|
||||
'notes' => ['nullable', 'string', 'max:10000'],
|
||||
]);
|
||||
$this->assessments->saveAnswers(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$validated['answers'] ?? [],
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
array_key_exists('notes', $validated) ? $validated['notes'] : null,
|
||||
);
|
||||
$assessment->refresh();
|
||||
}
|
||||
|
||||
$this->assessments->complete(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('care.assessments.show', $assessment)
|
||||
->with('success', 'Assessment completed.');
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Assessment $assessment): RedirectResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeCapture($request);
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
$this->assessments->cancel(
|
||||
$assessment,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('care.assessments.index', $assessment->patient)
|
||||
->with('success', 'Assessment cancelled.');
|
||||
}
|
||||
|
||||
/**
|
||||
* FHIR R4 Bundle (Questionnaire + QuestionnaireResponse) download.
|
||||
*/
|
||||
public function fhirExport(Request $request, Assessment $assessment): StreamedResponse|JsonResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizeAssessment($request, $assessment);
|
||||
|
||||
$bundle = $this->fhir->exportBundle($assessment);
|
||||
$filename = 'assessment-'.$assessment->uuid.'-fhir.json';
|
||||
|
||||
if ($request->wantsJson() && ! $request->boolean('download')) {
|
||||
return response()->json($bundle);
|
||||
}
|
||||
|
||||
return response()->streamDownload(
|
||||
function () use ($bundle) {
|
||||
echo json_encode($bundle, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
},
|
||||
$filename,
|
||||
['Content-Type' => 'application/fhir+json'],
|
||||
);
|
||||
}
|
||||
|
||||
protected function assertEngineEnabled(Request $request): void
|
||||
{
|
||||
abort_unless(
|
||||
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
protected function authorizeCapture(Request $request): void
|
||||
{
|
||||
abort_unless($this->canCapture($request), 403);
|
||||
}
|
||||
|
||||
protected function canCapture(Request $request): bool
|
||||
{
|
||||
$member = $this->member($request);
|
||||
|
||||
return $this->permissions->can($member, 'assessments.capture')
|
||||
|| $this->permissions->can($member, 'assessments.manage');
|
||||
}
|
||||
|
||||
protected function authorizePatient(Request $request, Patient $patient): void
|
||||
{
|
||||
$this->authorizeOwner($request, $patient);
|
||||
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||
{
|
||||
$this->authorizeOwner($request, $consultation);
|
||||
$consultation->loadMissing('visit');
|
||||
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function authorizeAssessment(Request $request, Assessment $assessment): void
|
||||
{
|
||||
$this->authorizeOwner($request, $assessment);
|
||||
abort_unless($assessment->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $assessment->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{consultation_uuid?: ?string, visit_uuid?: ?string} $validated
|
||||
* @return array{consultation?: Consultation, visit?: Visit, actor: string}
|
||||
*/
|
||||
protected function resolveContext(Request $request, Patient $patient, array $validated): array
|
||||
{
|
||||
$context = ['actor' => $this->ownerRef($request)];
|
||||
|
||||
if (! empty($validated['consultation_uuid'])) {
|
||||
$consultation = Consultation::query()
|
||||
->where('uuid', $validated['consultation_uuid'])
|
||||
->where('patient_id', $patient->id)
|
||||
->firstOrFail();
|
||||
$this->authorizeConsultation($request, $consultation);
|
||||
$consultation->loadMissing('visit');
|
||||
$context['consultation'] = $consultation;
|
||||
$context['visit'] = $consultation->visit;
|
||||
} elseif (! empty($validated['visit_uuid'])) {
|
||||
$visit = Visit::query()
|
||||
->where('uuid', $validated['visit_uuid'])
|
||||
->where('patient_id', $patient->id)
|
||||
->firstOrFail();
|
||||
$this->authorizeOwner($request, $visit);
|
||||
abort_unless($visit->organization_id === $this->organization($request)->id, 404);
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $visit->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
$context['visit'] = $visit;
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Patient;
|
||||
use App\Services\Care\AssessmentService;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use App\Services\Care\OutcomeTrendService;
|
||||
use App\Services\Care\PlanService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OutcomeController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(
|
||||
protected OutcomeTrendService $trends,
|
||||
protected AssessmentService $assessments,
|
||||
protected CareFeatures $features,
|
||||
protected CarePermissions $permissions,
|
||||
protected PlanService $plans,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Per-patient outcome history + instrument score trends (free).
|
||||
*/
|
||||
public function index(Request $request, Patient $patient): View
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
$ownerRef = $this->ownerRef($request);
|
||||
|
||||
$outcomeSeries = $this->trends->outcomeSeries($patient, $ownerRef, $branchScope);
|
||||
$scores = $this->trends->instrumentScores($patient, $ownerRef, $branchScope);
|
||||
$vitals = $this->trends->recentVitals($patient, $ownerRef);
|
||||
|
||||
$canCapture = $this->permissions->can($this->member($request), 'assessments.capture')
|
||||
|| $this->permissions->can($this->member($request), 'assessments.manage');
|
||||
|
||||
// Org-level multi-patient analytics is Enterprise (KD-18); surface a notice only.
|
||||
$canOrgAnalytics = $this->plans->isEnterprise($this->organization($request));
|
||||
|
||||
return view('care.outcomes.index', [
|
||||
'patient' => $patient,
|
||||
'outcomeSeries' => $outcomeSeries,
|
||||
'scores' => $scores,
|
||||
'vitals' => $vitals,
|
||||
'canCapture' => $canCapture,
|
||||
'canOrgAnalytics' => $canOrgAnalytics,
|
||||
'qolSeries' => $this->trends->numericOutcomeSeries($patient, $ownerRef, 'quality_of_life', $branchScope),
|
||||
'painSeries' => $this->trends->numericOutcomeSeries($patient, $ownerRef, 'pain_score', $branchScope),
|
||||
'statuses' => config('care.assessment_statuses'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new outcome_core draft for the patient.
|
||||
*/
|
||||
public function store(Request $request, Patient $patient): RedirectResponse
|
||||
{
|
||||
$this->assertEngineEnabled($request);
|
||||
abort_unless(
|
||||
$this->permissions->can($this->member($request), 'assessments.capture')
|
||||
|| $this->permissions->can($this->member($request), 'assessments.manage'),
|
||||
403,
|
||||
);
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$assessment = $this->assessments->start(
|
||||
$patient,
|
||||
OutcomeTrendService::OUTCOME_TEMPLATE_CODE,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
['actor' => $this->ownerRef($request)],
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('care.assessments.show', $assessment)
|
||||
->with('success', 'Outcome assessment started.');
|
||||
}
|
||||
|
||||
protected function assertEngineEnabled(Request $request): void
|
||||
{
|
||||
abort_unless(
|
||||
$this->features->enabled($this->organization($request), CareFeatures::ASSESSMENTS_ENGINE),
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
protected function authorizePatient(Request $request, Patient $patient): void
|
||||
{
|
||||
$this->authorizeOwner($request, $patient);
|
||||
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Care;
|
||||
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ClinicalPathway;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\Patient;
|
||||
use App\Models\PatientPathway;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use App\Services\Care\PathwayService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PathwayController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(
|
||||
protected PathwayService $pathways,
|
||||
protected CareFeatures $features,
|
||||
protected CarePermissions $permissions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Patient $patient): View
|
||||
{
|
||||
$this->assertPathwaysEnabled($request);
|
||||
$this->authorizeAbility($request, 'assessments.view');
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$active = $this->pathways->activeFor($patient);
|
||||
$history = PatientPathway::query()
|
||||
->owned($this->ownerRef($request))
|
||||
->where('patient_id', $patient->id)
|
||||
->with('pathway')
|
||||
->orderByDesc('activated_at')
|
||||
->paginate(20);
|
||||
|
||||
$catalog = ClinicalPathway::query()->active()->orderBy('sort_order')->get();
|
||||
$canManage = $this->permissions->can($this->member($request), 'pathways.manage');
|
||||
|
||||
return view('care.pathways.index', [
|
||||
'patient' => $patient,
|
||||
'active' => $active,
|
||||
'history' => $history,
|
||||
'catalog' => $catalog,
|
||||
'canManage' => $canManage,
|
||||
'statuses' => config('care.patient_pathway_statuses'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Patient $patient): RedirectResponse
|
||||
{
|
||||
$this->assertPathwaysEnabled($request);
|
||||
$this->authorizeAbility($request, 'pathways.manage');
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$validated = $request->validate([
|
||||
'pathway_code' => ['required', 'string', 'max:100'],
|
||||
'consultation_uuid' => ['nullable', 'uuid'],
|
||||
'activation_diagnosis_text' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$pathway = ClinicalPathway::findByCode($validated['pathway_code']);
|
||||
abort_unless($pathway, 422, 'Unknown or inactive pathway.');
|
||||
|
||||
$consultation = null;
|
||||
if (! empty($validated['consultation_uuid'])) {
|
||||
$consultation = Consultation::query()
|
||||
->where('uuid', $validated['consultation_uuid'])
|
||||
->where('patient_id', $patient->id)
|
||||
->firstOrFail();
|
||||
$this->authorizeConsultation($request, $consultation);
|
||||
$consultation->loadMissing(['visit', 'diagnoses']);
|
||||
}
|
||||
|
||||
$patientPathway = $this->pathways->activate(
|
||||
$patient,
|
||||
$pathway,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
[
|
||||
'consultation' => $consultation,
|
||||
'activation_diagnosis_text' => $validated['activation_diagnosis_text'] ?? null,
|
||||
'actor' => $this->ownerRef($request),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('care.pathways.index', $patient)
|
||||
->with('success', "Pathway “{$pathway->name}” activated.");
|
||||
}
|
||||
|
||||
public function deactivate(Request $request, Patient $patient, PatientPathway $patientPathway): RedirectResponse
|
||||
{
|
||||
$this->assertPathwaysEnabled($request);
|
||||
$this->authorizeAbility($request, 'pathways.manage');
|
||||
$this->authorizePatient($request, $patient);
|
||||
abort_unless($patientPathway->patient_id === $patient->id, 404);
|
||||
$this->authorizeOwner($request, $patientPathway);
|
||||
|
||||
$this->pathways->deactivate(
|
||||
$patientPathway,
|
||||
$this->ownerRef($request),
|
||||
$this->member($request),
|
||||
$this->ownerRef($request),
|
||||
);
|
||||
|
||||
return back()->with('success', 'Pathway deactivated.');
|
||||
}
|
||||
|
||||
public function suggestions(Request $request, Consultation $consultation): JsonResponse|RedirectResponse
|
||||
{
|
||||
$this->assertPathwaysEnabled($request);
|
||||
$this->authorizeAbility($request, 'consultations.view');
|
||||
$this->authorizeConsultation($request, $consultation);
|
||||
|
||||
$consultation->loadMissing(['diagnoses', 'patient']);
|
||||
$suggestions = $this->pathways->suggest($consultation->patient, $consultation->diagnoses);
|
||||
|
||||
$payload = [
|
||||
'data' => $suggestions->map(fn (array $row) => [
|
||||
'pathway_code' => $row['pathway_code'],
|
||||
'pathway_name' => $row['pathway_name'],
|
||||
'rank' => $row['rank'],
|
||||
'match_reason' => $row['match_reason'],
|
||||
'already_active' => $row['already_active'],
|
||||
])->values()->all(),
|
||||
];
|
||||
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
// HTML fallthrough not used — consultation show embeds suggestions server-side.
|
||||
return back()->with('pathway_suggestions', $payload['data']);
|
||||
}
|
||||
|
||||
protected function assertPathwaysEnabled(Request $request): void
|
||||
{
|
||||
$org = $this->organization($request);
|
||||
// Pathways require assessments engine; suggestions flag is optional UX.
|
||||
abort_unless(
|
||||
$this->features->enabled($org, CareFeatures::ASSESSMENTS_ENGINE),
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
protected function authorizePatient(Request $request, Patient $patient): void
|
||||
{
|
||||
$this->authorizeOwner($request, $patient);
|
||||
abort_unless($patient->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $patient->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
protected function authorizeConsultation(Request $request, Consultation $consultation): void
|
||||
{
|
||||
$this->authorizeOwner($request, $consultation);
|
||||
$consultation->loadMissing('visit');
|
||||
abort_unless($consultation->visit->organization_id === $this->organization($request)->id, 404);
|
||||
|
||||
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
|
||||
if ($branchId !== null && $consultation->visit->branch_id !== $branchId) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Patient;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\CarePermissions;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use App\Services\Care\PatientService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -91,13 +93,41 @@ class PatientController extends Controller
|
||||
$this->authorizePatient($request, $patient);
|
||||
|
||||
$dashboard = $this->patients->dashboard($patient);
|
||||
$canManage = app(\App\Services\Care\CarePermissions::class)
|
||||
->can($this->member($request), 'patients.manage');
|
||||
$permissions = app(CarePermissions::class);
|
||||
$member = $this->member($request);
|
||||
$organization = $this->organization($request);
|
||||
$canManage = $permissions->can($member, 'patients.manage');
|
||||
$credential = app(\App\Services\Messaging\MessagingCredentialsService::class)
|
||||
->forOrganization($this->organization($request));
|
||||
->forOrganization($organization);
|
||||
$suiteEmailReady = app(\App\Services\Billing\PlatformEmailClient::class)->isConfigured();
|
||||
$suiteSmsReady = app(\App\Services\Billing\PlatformSmsClient::class)->isConfigured();
|
||||
|
||||
$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')
|
||||
);
|
||||
$recentAssessments = collect();
|
||||
$latestUniversalIntake = null;
|
||||
if ($canViewAssessments) {
|
||||
$branchScope = app(OrganizationResolver::class)->branchScope($member);
|
||||
$recentAssessments = $patient->assessments()
|
||||
->with('template')
|
||||
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
||||
->orderByDesc('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$latestUniversalIntake = $patient->assessments()
|
||||
->with(['template', 'answers.question'])
|
||||
->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope))
|
||||
->whereHas('template', fn ($q) => $q->where('code', 'universal_intake'))
|
||||
->whereIn('status', [\App\Models\Assessment::STATUS_DRAFT, \App\Models\Assessment::STATUS_COMPLETED])
|
||||
->orderByDesc('created_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
return view('care.patients.show', array_merge($dashboard, [
|
||||
'canManage' => $canManage,
|
||||
'messagingSmsReady' => $suiteSmsReady || $credential->hasValidSms(),
|
||||
@@ -109,6 +139,12 @@ class PatientController extends Controller
|
||||
'genders' => config('care.genders'),
|
||||
'allergySeverities' => config('care.allergy_severities'),
|
||||
'documentTypes' => config('care.document_types'),
|
||||
'assessmentsEnabled' => $assessmentsEnabled,
|
||||
'canViewAssessments' => $canViewAssessments,
|
||||
'canCaptureAssessment' => $canCaptureAssessment,
|
||||
'recentAssessments' => $recentAssessments,
|
||||
'latestUniversalIntake' => $latestUniversalIntake,
|
||||
'assessmentStatuses' => config('care.assessment_statuses'),
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace App\Http\Controllers\Care;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||
use App\Models\Branch;
|
||||
use App\Services\Care\CareFeatures;
|
||||
use App\Services\Care\OrganizationResolver;
|
||||
use App\Services\Care\PlanService;
|
||||
use App\Services\Care\ReportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -47,14 +49,7 @@ class ReportController extends Controller
|
||||
|
||||
[$from, $to] = $this->dateRange($request);
|
||||
|
||||
$data = match ($type) {
|
||||
'patients' => $this->reports->patientsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||
'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||
'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||
'finance' => $this->reports->financeReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||
'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organization->id, $from, $to)],
|
||||
default => abort(404),
|
||||
};
|
||||
$data = $this->reportData($request, $type, $organization->id, $from, $to, $branchId);
|
||||
|
||||
$branches = Branch::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
@@ -87,14 +82,7 @@ class ReportController extends Controller
|
||||
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
|
||||
[$from, $to] = $this->dateRange($request);
|
||||
|
||||
$data = match ($type) {
|
||||
'patients' => $this->reports->patientsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||
'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||
'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||
'finance' => $this->reports->financeReport($this->ownerRef($request), $organization->id, $from, $to, $branchId),
|
||||
'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organization->id, $from, $to)],
|
||||
default => abort(404),
|
||||
};
|
||||
$data = $this->reportData($request, $type, $organization->id, $from, $to, $branchId);
|
||||
|
||||
$filename = "care-report-{$type}-".now()->format('Y-m-d').'.csv';
|
||||
|
||||
@@ -105,6 +93,20 @@ class ReportController extends Controller
|
||||
foreach ($data['diagnoses'] as $row) {
|
||||
fputcsv($handle, [$row->description, $row->total]);
|
||||
}
|
||||
} elseif ($type === 'assessments') {
|
||||
fputcsv($handle, ['Section', 'Key', 'Value']);
|
||||
foreach ($data['summary'] ?? [] as $key => $value) {
|
||||
fputcsv($handle, ['summary', $key, $value]);
|
||||
}
|
||||
foreach ($data['by_template'] ?? [] as $row) {
|
||||
fputcsv($handle, ['template', $row->template_code, "{$row->total} total / {$row->completed} completed"]);
|
||||
}
|
||||
foreach ($data['by_pathway'] ?? [] as $row) {
|
||||
fputcsv($handle, ['pathway', $row->pathway_code, "{$row->total} activations"]);
|
||||
}
|
||||
foreach ($data['score_averages'] ?? [] as $row) {
|
||||
fputcsv($handle, ['score_avg', $row->template_code, round((float) $row->avg_total, 2)]);
|
||||
}
|
||||
} else {
|
||||
fputcsv($handle, ['Metric', 'Value']);
|
||||
foreach ($data as $key => $value) {
|
||||
@@ -115,10 +117,39 @@ class ReportController extends Controller
|
||||
}, $filename, ['Content-Type' => 'text/csv']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function reportData(Request $request, string $type, int $organizationId, Carbon $from, Carbon $to, ?int $branchId): array
|
||||
{
|
||||
return match ($type) {
|
||||
'patients' => $this->reports->patientsReport($this->ownerRef($request), $organizationId, $from, $to, $branchId),
|
||||
'appointments' => $this->reports->appointmentsReport($this->ownerRef($request), $organizationId, $from, $to, $branchId),
|
||||
'laboratory' => $this->reports->laboratoryReport($this->ownerRef($request), $organizationId, $from, $to, $branchId),
|
||||
'finance' => $this->reports->financeReport($this->ownerRef($request), $organizationId, $from, $to, $branchId),
|
||||
'clinical' => ['diagnoses' => $this->reports->clinicalReport($this->ownerRef($request), $organizationId, $from, $to)],
|
||||
'assessments' => $this->reports->assessmentsReport($this->ownerRef($request), $organizationId, $from, $to, $branchId),
|
||||
default => abort(404),
|
||||
};
|
||||
}
|
||||
|
||||
protected function authorizeReport(Request $request, string $type): void
|
||||
{
|
||||
abort_unless(array_key_exists($type, config('care.report_types')), 404);
|
||||
$this->authorizeAbility($request, 'reports.finance.view');
|
||||
|
||||
if ($type === 'assessments') {
|
||||
$organization = $this->organization($request);
|
||||
abort_unless(
|
||||
app(CareFeatures::class)->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE),
|
||||
404,
|
||||
);
|
||||
abort_unless(
|
||||
app(PlanService::class)->hasFeature($organization, 'assessment_analytics'),
|
||||
403,
|
||||
'Assessment analytics require an Enterprise plan.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user