Deploy Ladill Care / deploy (push) Failing after 1m9s
Enforce service-queue gates, cashier settlements, and clinical stage progression so patient journeys cannot bypass configured payment or authorization rules. Co-authored-by: Cursor <cursoragent@cursor.com>
309 lines
14 KiB
PHP
309 lines
14 KiB
PHP
<?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\InvestigationType;
|
|
use App\Models\Practitioner;
|
|
use App\Services\Care\AssessmentService;
|
|
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(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(CarePermissions::class)
|
|
->can($this->member($request), 'consultations.manage');
|
|
$canVitals = app(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);
|
|
|
|
$completed = $this->consultations->complete(
|
|
$consultation,
|
|
$this->ownerRef($request),
|
|
$this->ownerRef($request),
|
|
);
|
|
|
|
app(ConsultationReturnContext::class)->forget();
|
|
|
|
$completed->loadMissing(['appointment.organization']);
|
|
if ($completed->appointment
|
|
&& $completed->appointment->organization
|
|
&& app(CareFeatures::class)->enabled(
|
|
$completed->appointment->organization,
|
|
CareFeatures::WORKFLOW_ENGINE,
|
|
)) {
|
|
return redirect()->route('care.appointments.show', $completed->appointment)
|
|
->with('success', 'Consultation completed. Continue the patient journey from the next stage.');
|
|
}
|
|
|
|
return redirect()->route('care.queue.index')
|
|
->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);
|
|
}
|
|
}
|