Files
ladill-care/app/Http/Controllers/Care/ConsultationController.php
T
isaaccladandCursor 94e53d05bf
Deploy Ladill Care / deploy (push) Successful in 1m7s
Keep consultation context on nested clinical pages and add Call again on patient queue cards.
Doctors can return to the active consultation from forms, lab, prescriptions, pathways, and bills; queue cards can re-announce called/serving tickets via the existing Queue recall bridge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 17:48:34 +00:00

297 lines
13 KiB
PHP

<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
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\ConsultationReturnContext;
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;
class ConsultationController extends Controller
{
use ScopesToAccount;
public function __construct(
protected ConsultationService $consultations,
) {}
public function show(Request $request, Consultation $consultation): View
{
$this->authorizeAbility($request, 'consultations.view');
$this->authorizeConsultation($request, $consultation);
$consultation->load([
'patient', 'practitioner', 'visit', 'appointment',
'vitalSigns', 'diagnoses', 'documents',
'investigationRequests.investigationType', 'prescriptions.items',
]);
app(ConsultationReturnContext::class)->remember($consultation);
$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');
$careFeatures = app(CareFeatures::class);
$assessmentsEnabled = $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE);
if ($assessmentsEnabled) {
$careFeatures->ensureAssessmentCatalog();
}
$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', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
$investigationTypes = InvestigationType::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('care.consultations.show', [
'consultation' => $consultation,
'practitioners' => $practitioners,
'investigationTypes' => $investigationTypes,
'canManage' => $canManage,
'canVitals' => $canVitals,
'canRequestInvestigations' => $canRequestInvestigations,
'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(),
]);
}
public function update(Request $request, Consultation $consultation): RedirectResponse
{
$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');
abort_unless($canManage || $canVitals, 403);
$this->authorizeConsultation($request, $consultation);
abort_if($consultation->status === Consultation::STATUS_COMPLETED, 422);
$validated = $this->validatedConsultationData($request, $canManage, $canVitals);
$this->consultations->save(
$consultation,
$this->ownerRef($request),
$validated,
$this->ownerRef($request),
);
$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
{
$this->authorizeAbility($request, 'consultations.manage');
$this->authorizeConsultation($request, $consultation);
$this->consultations->complete(
$consultation,
$this->ownerRef($request),
$this->ownerRef($request),
);
app(ConsultationReturnContext::class)->forget();
return redirect()->route('care.patients.show', $consultation->patient)
->with('success', 'Consultation completed.');
}
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);
}
}
/**
* @return array<string, mixed>
*/
protected function validatedConsultationData(Request $request, bool $canManage, bool $canVitals): array
{
$rules = [];
if ($canManage) {
$rules = array_merge($rules, [
'symptoms' => ['nullable', 'string', 'max:10000'],
'clinical_notes' => ['nullable', 'string', 'max:20000'],
'practitioner_id' => ['nullable', 'integer', 'exists:care_practitioners,id'],
'diagnoses' => ['nullable', 'array'],
'diagnoses.*.code' => ['nullable', 'string', 'max:50'],
'diagnoses.*.description' => ['nullable', 'string', 'max:500'],
'diagnoses.*.is_primary' => ['nullable', 'boolean'],
'diagnoses.*.notes' => ['nullable', 'string', 'max:2000'],
'documents' => ['nullable', 'array'],
'documents.*' => ['file', 'max:10240', 'mimes:pdf,jpeg,png,jpg,webp'],
]);
$canVitals = true;
}
if ($canVitals) {
$rules['vitals'] = ['nullable', 'array'];
$rules['vitals.bp_systolic'] = ['nullable', 'integer', 'min:50', 'max:300'];
$rules['vitals.bp_diastolic'] = ['nullable', 'integer', 'min:30', 'max:200'];
$rules['vitals.pulse'] = ['nullable', 'integer', 'min:20', 'max:250'];
$rules['vitals.temperature'] = ['nullable', 'numeric', 'min:30', 'max:45'];
$rules['vitals.weight_kg'] = ['nullable', 'numeric', 'min:0', 'max:500'];
$rules['vitals.height_cm'] = ['nullable', 'numeric', 'min:0', 'max:300'];
$rules['vitals.spo2'] = ['nullable', 'integer', 'min:50', 'max:100'];
$rules['vitals.respiratory_rate'] = ['nullable', 'integer', 'min:5', 'max:80'];
}
return $request->validate($rules);
}
}