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,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Care;
|
||||
|
||||
use App\Models\Assessment;
|
||||
|
||||
interface ScoresAssessment
|
||||
{
|
||||
/**
|
||||
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||
*/
|
||||
public function score(Assessment $assessment): array;
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Assessment extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected $table = 'care_assessments';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'patient_id',
|
||||
'template_id', 'consultation_id', 'visit_id', 'patient_pathway_id', 'practitioner_id',
|
||||
'status', 'assessed_at', 'completed_at', 'completed_by', 'started_by', 'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'assessed_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Assessment $assessment) {
|
||||
if (! $assessment->uuid) {
|
||||
$assessment->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function branch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Branch::class, 'branch_id');
|
||||
}
|
||||
|
||||
public function patient(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Patient::class, 'patient_id');
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AssessmentTemplate::class, 'template_id');
|
||||
}
|
||||
|
||||
public function consultation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Consultation::class, 'consultation_id');
|
||||
}
|
||||
|
||||
public function visit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Visit::class, 'visit_id');
|
||||
}
|
||||
|
||||
public function patientPathway(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PatientPathway::class, 'patient_pathway_id');
|
||||
}
|
||||
|
||||
public function practitioner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Practitioner::class, 'practitioner_id');
|
||||
}
|
||||
|
||||
public function answers(): HasMany
|
||||
{
|
||||
return $this->hasMany(AssessmentAnswer::class, 'assessment_id');
|
||||
}
|
||||
|
||||
public function score(): HasOne
|
||||
{
|
||||
return $this->hasOne(AssessmentScore::class, 'assessment_id');
|
||||
}
|
||||
|
||||
public function isDraft(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DRAFT;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AssessmentAnswer extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
protected $table = 'care_assessment_answers';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'assessment_id', 'question_id',
|
||||
'value_text', 'value_number', 'value_boolean', 'value_date', 'value_json',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'value_number' => 'decimal:4',
|
||||
'value_boolean' => 'boolean',
|
||||
'value_date' => 'datetime',
|
||||
'value_json' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function assessment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Assessment::class, 'assessment_id');
|
||||
}
|
||||
|
||||
public function question(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AssessmentQuestion::class, 'question_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative value for the given answer type (unused columns must stay null at write time).
|
||||
*/
|
||||
public function authoritativeValue(?string $answerType = null): mixed
|
||||
{
|
||||
$type = $answerType ?? $this->question?->answer_type;
|
||||
|
||||
return match ($type) {
|
||||
AssessmentQuestion::TYPE_TEXT,
|
||||
AssessmentQuestion::TYPE_TEXTAREA,
|
||||
AssessmentQuestion::TYPE_SINGLE_CHOICE => $this->value_text,
|
||||
AssessmentQuestion::TYPE_NUMBER,
|
||||
AssessmentQuestion::TYPE_INTEGER,
|
||||
AssessmentQuestion::TYPE_SCALE,
|
||||
AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||
AssessmentQuestion::TYPE_CALCULATED => $this->value_number,
|
||||
AssessmentQuestion::TYPE_BOOLEAN => $this->value_boolean,
|
||||
AssessmentQuestion::TYPE_DATE,
|
||||
AssessmentQuestion::TYPE_DATETIME => $this->value_date,
|
||||
AssessmentQuestion::TYPE_MULTI_CHOICE => $this->value_json,
|
||||
default => $this->value_text ?? $this->value_number ?? $this->value_boolean ?? $this->value_date ?? $this->value_json,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AssessmentQuestion extends Model
|
||||
{
|
||||
public const TYPE_TEXT = 'text';
|
||||
|
||||
public const TYPE_TEXTAREA = 'textarea';
|
||||
|
||||
public const TYPE_NUMBER = 'number';
|
||||
|
||||
public const TYPE_INTEGER = 'integer';
|
||||
|
||||
public const TYPE_BOOLEAN = 'boolean';
|
||||
|
||||
public const TYPE_DATE = 'date';
|
||||
|
||||
public const TYPE_DATETIME = 'datetime';
|
||||
|
||||
public const TYPE_SINGLE_CHOICE = 'single_choice';
|
||||
|
||||
public const TYPE_MULTI_CHOICE = 'multi_choice';
|
||||
|
||||
public const TYPE_SCALE = 'scale';
|
||||
|
||||
public const TYPE_SCORE_ITEM = 'score_item';
|
||||
|
||||
public const TYPE_CALCULATED = 'calculated';
|
||||
|
||||
protected $table = 'care_assessment_questions';
|
||||
|
||||
protected $fillable = [
|
||||
'template_id', 'code', 'section', 'label', 'help_text', 'answer_type',
|
||||
'options', 'is_required', 'sort_order', 'score_key', 'validation',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'options' => 'array',
|
||||
'validation' => 'array',
|
||||
'is_required' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AssessmentTemplate::class, 'template_id');
|
||||
}
|
||||
|
||||
public function answers(): HasMany
|
||||
{
|
||||
return $this->hasMany(AssessmentAnswer::class, 'question_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AssessmentScore extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
|
||||
protected $table = 'care_assessment_scores';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'assessment_id', 'template_code',
|
||||
'total_score', 'max_score', 'subscores', 'severity_label', 'computed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total_score' => 'decimal:4',
|
||||
'max_score' => 'decimal:4',
|
||||
'subscores' => 'array',
|
||||
'computed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function assessment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Assessment::class, 'assessment_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Platform catalog row (no owner_ref). System templates in v1 always have null organization_id.
|
||||
*/
|
||||
class AssessmentTemplate extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
public const CATEGORY_UNIVERSAL = 'universal';
|
||||
|
||||
public const CATEGORY_DISEASE = 'disease';
|
||||
|
||||
public const CATEGORY_OUTCOME = 'outcome';
|
||||
|
||||
public const CATEGORY_SCREENING = 'screening';
|
||||
|
||||
protected $table = 'care_assessment_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'organization_id', 'code', 'name', 'category', 'description',
|
||||
'version', 'is_current', 'scoring_strategy', 'meta', 'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'version' => 'integer',
|
||||
'is_current' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'meta' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (AssessmentTemplate $template) {
|
||||
if (! $template->uuid) {
|
||||
$template->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function questions(): HasMany
|
||||
{
|
||||
return $this->hasMany(AssessmentQuestion::class, 'template_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function assessments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Assessment::class, 'template_id');
|
||||
}
|
||||
|
||||
public function scopeSystem(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull($this->getTable().'.organization_id');
|
||||
}
|
||||
|
||||
public function scopeCurrent(Builder $query): Builder
|
||||
{
|
||||
return $query->where($this->getTable().'.is_current', true)
|
||||
->where($this->getTable().'.is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current system template for a stable code (e.g. nihss, universal_intake).
|
||||
*/
|
||||
public static function currentSystemByCode(string $code): ?self
|
||||
{
|
||||
return static::query()
|
||||
->system()
|
||||
->current()
|
||||
->where('code', $code)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Platform catalog pathway (no owner_ref). */
|
||||
class ClinicalPathway extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'care_clinical_pathways';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'code', 'name', 'description', 'match_rules',
|
||||
'is_active', 'sort_order', 'meta',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'match_rules' => 'array',
|
||||
'meta' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (ClinicalPathway $pathway) {
|
||||
if (! $pathway->uuid) {
|
||||
$pathway->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
public function templates(): HasMany
|
||||
{
|
||||
return $this->hasMany(PathwayTemplate::class, 'pathway_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function patientPathways(): HasMany
|
||||
{
|
||||
return $this->hasMany(PatientPathway::class, 'pathway_id');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public static function findByCode(string $code): ?self
|
||||
{
|
||||
return static::query()->where('code', $code)->where('is_active', true)->first();
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,11 @@ class Consultation extends Model
|
||||
return $this->hasMany(Diagnosis::class, 'consultation_id');
|
||||
}
|
||||
|
||||
public function assessments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Assessment::class, 'consultation_id');
|
||||
}
|
||||
|
||||
public function documents(): HasMany
|
||||
{
|
||||
return $this->hasMany(ConsultationDocument::class, 'consultation_id');
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** Catalog binding: pathway → template_code (resolve is_current at assessment start). */
|
||||
class PathwayTemplate extends Model
|
||||
{
|
||||
public const PHASE_ACUTE = 'acute';
|
||||
|
||||
public const PHASE_FOLLOW_UP = 'follow_up';
|
||||
|
||||
public const PHASE_ANY = 'any';
|
||||
|
||||
protected $table = 'care_pathway_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'pathway_id', 'template_code', 'is_required_on_activation', 'phase', 'sort_order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_required_on_activation' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function pathway(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClinicalPathway::class, 'pathway_id');
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,16 @@ class Patient extends Model
|
||||
return $this->hasMany(Visit::class, 'patient_id');
|
||||
}
|
||||
|
||||
public function assessments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Assessment::class, 'patient_id');
|
||||
}
|
||||
|
||||
public function pathways(): HasMany
|
||||
{
|
||||
return $this->hasMany(PatientPathway::class, 'patient_id');
|
||||
}
|
||||
|
||||
public function investigationRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvestigationRequest::class, 'patient_id');
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PatientPathway extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_RESOLVED = 'resolved';
|
||||
|
||||
public const STATUS_INACTIVE = 'inactive';
|
||||
|
||||
protected $table = 'care_patient_pathways';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'owner_ref', 'organization_id', 'patient_id', 'pathway_id',
|
||||
'status', 'activated_at', 'activated_by', 'activation_consultation_id',
|
||||
'activation_diagnosis_text', 'resolved_at', 'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'activated_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (PatientPathway $row) {
|
||||
if (! $row->uuid) {
|
||||
$row->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
|
||||
public function patient(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Patient::class, 'patient_id');
|
||||
}
|
||||
|
||||
public function pathway(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ClinicalPathway::class, 'pathway_id');
|
||||
}
|
||||
|
||||
public function activationConsultation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Consultation::class, 'activation_consultation_id');
|
||||
}
|
||||
|
||||
public function assessments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Assessment::class, 'patient_pathway_id');
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,11 @@ class Visit extends Model
|
||||
return $this->hasMany(Consultation::class, 'visit_id');
|
||||
}
|
||||
|
||||
public function assessments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Assessment::class, 'visit_id');
|
||||
}
|
||||
|
||||
public function bill(): HasOne
|
||||
{
|
||||
return $this->hasOne(Bill::class, 'visit_id');
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Assessment;
|
||||
use App\Models\AssessmentAnswer;
|
||||
use App\Models\AssessmentQuestion;
|
||||
use App\Models\AssessmentTemplate;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\Member;
|
||||
use App\Models\Patient;
|
||||
use App\Models\Visit;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class AssessmentService
|
||||
{
|
||||
public function __construct(
|
||||
protected ScoringService $scoring,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start a draft assessment for the current system template by code.
|
||||
* Idempotent: returns existing draft for the same patient/template/(consultation).
|
||||
*
|
||||
* @param array{
|
||||
* consultation?: ?Consultation,
|
||||
* visit?: ?Visit,
|
||||
* patient_pathway?: ?\App\Models\PatientPathway,
|
||||
* practitioner_id?: ?int,
|
||||
* actor?: ?string,
|
||||
* } $context
|
||||
*/
|
||||
public function start(
|
||||
Patient $patient,
|
||||
string $templateCode,
|
||||
string $ownerRef,
|
||||
?Member $member,
|
||||
array $context = [],
|
||||
): Assessment {
|
||||
$template = AssessmentTemplate::currentSystemByCode($templateCode);
|
||||
|
||||
if (! $template) {
|
||||
throw ValidationException::withMessages([
|
||||
'template_code' => 'Unknown, inactive, or non-current assessment template.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->assertCaptureAllowed($member, $template);
|
||||
|
||||
$consultation = $context['consultation'] ?? null;
|
||||
$visit = $context['visit'] ?? $consultation?->visit;
|
||||
$patientPathway = $context['patient_pathway'] ?? null;
|
||||
$actor = $context['actor'] ?? null;
|
||||
|
||||
return DB::transaction(function () use ($patient, $template, $ownerRef, $consultation, $visit, $patientPathway, $context, $actor) {
|
||||
$query = Assessment::query()
|
||||
->where('patient_id', $patient->id)
|
||||
->where('template_id', $template->id)
|
||||
->where('status', Assessment::STATUS_DRAFT)
|
||||
->lockForUpdate();
|
||||
|
||||
if ($consultation) {
|
||||
$query->where('consultation_id', $consultation->id);
|
||||
} else {
|
||||
$query->whereNull('consultation_id');
|
||||
}
|
||||
|
||||
$existing = $query->first();
|
||||
if ($existing) {
|
||||
if ($patientPathway && ! $existing->patient_pathway_id) {
|
||||
$existing->update(['patient_pathway_id' => $patientPathway->id]);
|
||||
}
|
||||
|
||||
return $existing->load(['template.questions', 'answers.question', 'patient', 'consultation', 'visit', 'score']);
|
||||
}
|
||||
|
||||
$branchId = $visit?->branch_id ?? $patient->branch_id;
|
||||
|
||||
$assessment = Assessment::create([
|
||||
'owner_ref' => $ownerRef,
|
||||
'organization_id' => $patient->organization_id,
|
||||
'branch_id' => $branchId,
|
||||
'patient_id' => $patient->id,
|
||||
'template_id' => $template->id,
|
||||
'consultation_id' => $consultation?->id,
|
||||
'visit_id' => $visit?->id,
|
||||
'patient_pathway_id' => $patientPathway?->id,
|
||||
'practitioner_id' => $context['practitioner_id'] ?? $consultation?->practitioner_id,
|
||||
'status' => Assessment::STATUS_DRAFT,
|
||||
'started_by' => $actor,
|
||||
]);
|
||||
|
||||
AuditLogger::record(
|
||||
$ownerRef,
|
||||
'assessment.started',
|
||||
$patient->organization_id,
|
||||
$actor,
|
||||
Assessment::class,
|
||||
$assessment->id,
|
||||
['template_code' => $template->code, 'template_version' => $template->version],
|
||||
);
|
||||
|
||||
return $assessment->load(['template.questions', 'answers.question', 'patient', 'consultation', 'visit', 'score']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $answers keyed by question code
|
||||
*/
|
||||
public function saveAnswers(
|
||||
Assessment $assessment,
|
||||
string $ownerRef,
|
||||
array $answers,
|
||||
?Member $member = null,
|
||||
?string $actorRef = null,
|
||||
?string $notes = null,
|
||||
): Assessment {
|
||||
$this->assertOwner($assessment, $ownerRef);
|
||||
$this->assertDraft($assessment);
|
||||
$assessment->loadMissing('template.questions');
|
||||
$this->assertCaptureAllowed($member, $assessment->template);
|
||||
|
||||
DB::transaction(function () use ($assessment, $ownerRef, $answers, $notes) {
|
||||
foreach ($answers as $code => $raw) {
|
||||
$question = $assessment->template->questions->firstWhere('code', $code);
|
||||
if (! $question) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => "Unknown question code [{$code}].",
|
||||
]);
|
||||
}
|
||||
|
||||
if ($question->answer_type === AssessmentQuestion::TYPE_CALCULATED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeValue($question, $raw);
|
||||
|
||||
AssessmentAnswer::updateOrCreate(
|
||||
[
|
||||
'assessment_id' => $assessment->id,
|
||||
'question_id' => $question->id,
|
||||
],
|
||||
array_merge(
|
||||
['owner_ref' => $ownerRef],
|
||||
$normalized,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($notes !== null) {
|
||||
$assessment->update(['notes' => $notes]);
|
||||
}
|
||||
});
|
||||
|
||||
AuditLogger::record(
|
||||
$ownerRef,
|
||||
'assessment.updated',
|
||||
$assessment->organization_id,
|
||||
$actorRef,
|
||||
Assessment::class,
|
||||
$assessment->id,
|
||||
['template_code' => $assessment->template->code, 'answer_keys' => array_keys($answers)],
|
||||
);
|
||||
|
||||
return $assessment->fresh(['template.questions', 'answers.question', 'patient', 'consultation', 'visit']);
|
||||
}
|
||||
|
||||
public function complete(
|
||||
Assessment $assessment,
|
||||
string $ownerRef,
|
||||
?Member $member = null,
|
||||
?string $actorRef = null,
|
||||
): Assessment {
|
||||
$this->assertOwner($assessment, $ownerRef);
|
||||
$this->assertDraft($assessment);
|
||||
$assessment->loadMissing(['template.questions', 'answers']);
|
||||
$this->assertCaptureAllowed($member, $assessment->template);
|
||||
|
||||
$this->validateRequiredAnswers($assessment);
|
||||
|
||||
return DB::transaction(function () use ($assessment, $ownerRef, $actorRef) {
|
||||
// Score first so complete fails cleanly without marking completed if scoring 422s.
|
||||
$scoreMeta = [];
|
||||
if ($this->scoring->requiresScore($assessment)) {
|
||||
$score = $this->scoring->materialize($assessment);
|
||||
$scoreMeta = [
|
||||
'total_score' => $score->total_score,
|
||||
'max_score' => $score->max_score,
|
||||
];
|
||||
}
|
||||
|
||||
$assessment->update([
|
||||
'status' => Assessment::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
'completed_by' => $actorRef,
|
||||
'assessed_at' => $assessment->assessed_at ?? now(),
|
||||
]);
|
||||
|
||||
AuditLogger::record(
|
||||
$ownerRef,
|
||||
'assessment.completed',
|
||||
$assessment->organization_id,
|
||||
$actorRef,
|
||||
Assessment::class,
|
||||
$assessment->id,
|
||||
array_merge(['template_code' => $assessment->template->code], $scoreMeta),
|
||||
);
|
||||
|
||||
return $assessment->fresh(['template.questions', 'answers.question', 'patient', 'consultation', 'visit', 'score']);
|
||||
});
|
||||
}
|
||||
|
||||
public function cancel(
|
||||
Assessment $assessment,
|
||||
string $ownerRef,
|
||||
?Member $member = null,
|
||||
?string $actorRef = null,
|
||||
): Assessment {
|
||||
$this->assertOwner($assessment, $ownerRef);
|
||||
$this->assertDraft($assessment);
|
||||
$assessment->loadMissing('template');
|
||||
$this->assertCaptureAllowed($member, $assessment->template);
|
||||
|
||||
$assessment->update([
|
||||
'status' => Assessment::STATUS_CANCELLED,
|
||||
]);
|
||||
|
||||
AuditLogger::record(
|
||||
$ownerRef,
|
||||
'assessment.cancelled',
|
||||
$assessment->organization_id,
|
||||
$actorRef,
|
||||
Assessment::class,
|
||||
$assessment->id,
|
||||
['template_code' => $assessment->template->code],
|
||||
);
|
||||
|
||||
return $assessment->fresh(['template', 'patient']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function listForPatient(
|
||||
Patient $patient,
|
||||
string $ownerRef,
|
||||
array $filters = [],
|
||||
?int $branchScope = null,
|
||||
): LengthAwarePaginator {
|
||||
$query = Assessment::query()
|
||||
->owned($ownerRef)
|
||||
->where('patient_id', $patient->id)
|
||||
->with(['template', 'consultation', 'visit', 'practitioner'])
|
||||
->orderByDesc('assessed_at')
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($branchScope !== null) {
|
||||
$query->where('branch_id', $branchScope);
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['template_code'])) {
|
||||
$query->whereHas('template', fn ($q) => $q->where('code', $filters['template_code']));
|
||||
}
|
||||
|
||||
if (! empty($filters['category'])) {
|
||||
$query->whereHas('template', fn ($q) => $q->where('category', $filters['category']));
|
||||
}
|
||||
|
||||
if (! empty($filters['from'])) {
|
||||
$query->whereDate('assessed_at', '>=', $filters['from']);
|
||||
}
|
||||
|
||||
if (! empty($filters['to'])) {
|
||||
$query->whereDate('assessed_at', '<=', $filters['to']);
|
||||
}
|
||||
|
||||
$perPage = min(max((int) ($filters['per_page'] ?? 20), 1), 100);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function assertCaptureAllowed(?Member $member, AssessmentTemplate $template): void
|
||||
{
|
||||
if ($member === null) {
|
||||
throw new HttpException(403, 'Not authorized to capture assessments.');
|
||||
}
|
||||
|
||||
if (in_array($member->role, ['hospital_admin', 'super_admin'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roles = $template->meta['capture_roles']
|
||||
?? $this->defaultCaptureRoles($template->category);
|
||||
|
||||
if (! in_array($member->role, $roles, true)) {
|
||||
throw new HttpException(403, 'Your role cannot capture this assessment template.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function defaultCaptureRoles(string $category): array
|
||||
{
|
||||
return match ($category) {
|
||||
AssessmentTemplate::CATEGORY_DISEASE => ['doctor'],
|
||||
default => ['doctor', 'nurse'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw answer into typed columns (exactly one authoritative column set).
|
||||
*
|
||||
* @return array{
|
||||
* value_text: ?string,
|
||||
* value_number: ?string|null,
|
||||
* value_boolean: ?bool,
|
||||
* value_date: ?string,
|
||||
* value_json: mixed
|
||||
* }
|
||||
*/
|
||||
public function normalizeValue(AssessmentQuestion $question, mixed $raw): array
|
||||
{
|
||||
$empty = [
|
||||
'value_text' => null,
|
||||
'value_number' => null,
|
||||
'value_boolean' => null,
|
||||
'value_date' => null,
|
||||
'value_json' => null,
|
||||
];
|
||||
|
||||
if ($raw === null || $raw === '') {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$type = $question->answer_type;
|
||||
$options = $question->options ?? [];
|
||||
$validation = $question->validation ?? [];
|
||||
|
||||
return match ($type) {
|
||||
AssessmentQuestion::TYPE_TEXT,
|
||||
AssessmentQuestion::TYPE_TEXTAREA => array_merge($empty, [
|
||||
'value_text' => $this->validatedString($raw, $question->code, $validation),
|
||||
]),
|
||||
AssessmentQuestion::TYPE_SINGLE_CHOICE => array_merge($empty, [
|
||||
'value_text' => $this->validatedChoiceCode($raw, $question->code, $options),
|
||||
]),
|
||||
AssessmentQuestion::TYPE_NUMBER,
|
||||
AssessmentQuestion::TYPE_INTEGER,
|
||||
AssessmentQuestion::TYPE_SCALE => array_merge($empty, [
|
||||
'value_number' => $this->validatedNumber($raw, $question->code, $options, $validation, $type === AssessmentQuestion::TYPE_INTEGER),
|
||||
]),
|
||||
AssessmentQuestion::TYPE_SCORE_ITEM => array_merge($empty, [
|
||||
'value_number' => $this->validatedScoreItem($raw, $question->code, $options),
|
||||
]),
|
||||
AssessmentQuestion::TYPE_BOOLEAN => array_merge($empty, [
|
||||
'value_boolean' => filter_var($raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $raw,
|
||||
]),
|
||||
AssessmentQuestion::TYPE_DATE => array_merge($empty, [
|
||||
'value_date' => $this->validatedDate($raw, $question->code, false),
|
||||
]),
|
||||
AssessmentQuestion::TYPE_DATETIME => array_merge($empty, [
|
||||
'value_date' => $this->validatedDate($raw, $question->code, true),
|
||||
]),
|
||||
AssessmentQuestion::TYPE_MULTI_CHOICE => array_merge($empty, [
|
||||
'value_json' => $this->validatedMultiChoice($raw, $question->code, $options),
|
||||
]),
|
||||
AssessmentQuestion::TYPE_CALCULATED => $empty,
|
||||
default => throw ValidationException::withMessages([
|
||||
"answers.{$question->code}" => "Unsupported answer type [{$type}].",
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
protected function assertOwner(Assessment $assessment, string $ownerRef): void
|
||||
{
|
||||
if ($assessment->owner_ref !== $ownerRef) {
|
||||
throw new HttpException(404, 'Assessment not found.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertDraft(Assessment $assessment): void
|
||||
{
|
||||
if ($assessment->status !== Assessment::STATUS_DRAFT) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => 'Only draft assessments can be modified. Completed assessments are immutable.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateRequiredAnswers(Assessment $assessment): void
|
||||
{
|
||||
$answersByQuestion = $assessment->answers->keyBy('question_id');
|
||||
$errors = [];
|
||||
|
||||
foreach ($assessment->template->questions as $question) {
|
||||
if ($question->answer_type === AssessmentQuestion::TYPE_CALCULATED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $question->is_required) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$answer = $answersByQuestion->get($question->id);
|
||||
if (! $answer || $this->isEmptyAuthoritative($answer, $question->answer_type)) {
|
||||
$errors["answers.{$question->code}"] = "{$question->label} is required.";
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
|
||||
protected function isEmptyAuthoritative(AssessmentAnswer $answer, string $type): bool
|
||||
{
|
||||
$value = $answer->authoritativeValue($type);
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_array($value) && $value === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function validatedString(mixed $raw, string $code, array $validation): string
|
||||
{
|
||||
$value = is_scalar($raw) ? (string) $raw : '';
|
||||
$max = (int) ($validation['max'] ?? 10000);
|
||||
if (mb_strlen($value) > $max) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => "Must be at most {$max} characters.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function validatedChoiceCode(mixed $raw, string $code, array $options): string
|
||||
{
|
||||
$value = is_scalar($raw) ? (string) $raw : '';
|
||||
$choices = collect($options['choices'] ?? [])->pluck('code')->map(fn ($c) => (string) $c)->all();
|
||||
if ($choices !== [] && ! in_array($value, $choices, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => 'Invalid choice.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function validatedNumber(mixed $raw, string $code, array $options, array $validation, bool $integerOnly): string
|
||||
{
|
||||
if (! is_numeric($raw)) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => 'Must be a number.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($integerOnly && (float) $raw != (int) $raw) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => 'Must be an integer.',
|
||||
]);
|
||||
}
|
||||
|
||||
$number = (float) $raw;
|
||||
$min = $validation['min'] ?? $options['min'] ?? null;
|
||||
$max = $validation['max'] ?? $options['max'] ?? null;
|
||||
|
||||
if ($min !== null && $number < (float) $min) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => "Must be at least {$min}.",
|
||||
]);
|
||||
}
|
||||
if ($max !== null && $number > (float) $max) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => "Must be at most {$max}.",
|
||||
]);
|
||||
}
|
||||
|
||||
return (string) $number;
|
||||
}
|
||||
|
||||
protected function validatedScoreItem(mixed $raw, string $code, array $options): string
|
||||
{
|
||||
$choices = $options['choices'] ?? [];
|
||||
if (is_array($choices) && $choices !== []) {
|
||||
// Accept choice code or numeric score.
|
||||
if (is_string($raw) || is_int($raw)) {
|
||||
$asString = (string) $raw;
|
||||
foreach ($choices as $choice) {
|
||||
if ((string) ($choice['code'] ?? '') === $asString) {
|
||||
if (! array_key_exists('score', $choice)) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => 'Choice has no score.',
|
||||
]);
|
||||
}
|
||||
|
||||
return (string) $choice['score'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->validatedNumber($raw, $code, $options, [], false);
|
||||
}
|
||||
|
||||
protected function validatedDate(mixed $raw, string $code, bool $withTime): string
|
||||
{
|
||||
$format = $withTime ? 'Y-m-d H:i:s' : 'Y-m-d';
|
||||
$rules = [$withTime ? 'date' : 'date_format:Y-m-d'];
|
||||
$validator = Validator::make(
|
||||
['v' => $raw],
|
||||
['v' => $rules],
|
||||
);
|
||||
if ($validator->fails()) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => 'Invalid date.',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$dt = \Carbon\Carbon::parse($raw);
|
||||
|
||||
return $withTime ? $dt->format('Y-m-d H:i:s') : $dt->format('Y-m-d').' 00:00:00';
|
||||
} catch (\Throwable) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => 'Invalid date.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function validatedMultiChoice(mixed $raw, string $code, array $options): array
|
||||
{
|
||||
if (! is_array($raw)) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => 'Must be a list of choices.',
|
||||
]);
|
||||
}
|
||||
|
||||
$allowed = collect($options['choices'] ?? [])->pluck('code')->map(fn ($c) => (string) $c)->all();
|
||||
$values = array_values(array_map(fn ($v) => (string) $v, $raw));
|
||||
|
||||
foreach ($values as $value) {
|
||||
if ($allowed !== [] && ! in_array($value, $allowed, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$code}" => "Invalid choice [{$value}].",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Organization;
|
||||
|
||||
/**
|
||||
* Product rollout flags stored under organization settings.rollout.*
|
||||
* Distinct from PlanService entitlements (lab/pharmacy/billing plan features).
|
||||
*/
|
||||
class CareFeatures
|
||||
{
|
||||
public const ASSESSMENTS_ENGINE = 'assessments_engine';
|
||||
|
||||
public const PATHWAY_SUGGESTIONS = 'pathway_suggestions';
|
||||
|
||||
public const ASSESSMENT_REQUIRED_ON_COMPLETE = 'assessment_required_on_complete';
|
||||
|
||||
public function enabled(Organization $organization, string $flag): bool
|
||||
{
|
||||
$settings = $organization->settings ?? [];
|
||||
$rollout = is_array($settings['rollout'] ?? null) ? $settings['rollout'] : [];
|
||||
|
||||
return (bool) ($rollout[$flag] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $flags
|
||||
*/
|
||||
public function setFlags(Organization $organization, array $flags): Organization
|
||||
{
|
||||
$settings = $organization->settings ?? [];
|
||||
$rollout = is_array($settings['rollout'] ?? null) ? $settings['rollout'] : [];
|
||||
$settings['rollout'] = array_merge($rollout, $flags);
|
||||
$organization->settings = $settings;
|
||||
$organization->save();
|
||||
|
||||
return $organization->fresh();
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,14 @@ class CarePermissions
|
||||
'dashboard.view', 'patients.view', 'appointments.view',
|
||||
'consultations.view', 'consultations.manage',
|
||||
'investigations.request', 'prescriptions.manage', 'lab.results.view',
|
||||
'assessments.view', 'assessments.capture', 'assessments.manage',
|
||||
'pathways.manage',
|
||||
],
|
||||
'nurse' => [
|
||||
'dashboard.view', 'patients.view', 'appointments.view',
|
||||
'consultations.view', 'vitals.manage', 'queue.manage',
|
||||
'service_queues.console',
|
||||
'assessments.view', 'assessments.capture',
|
||||
],
|
||||
'lab_technician' => [
|
||||
'dashboard.view', 'patients.view', 'lab.view', 'lab.manage',
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Assessment;
|
||||
use App\Models\AssessmentQuestion;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Export a Care assessment as FHIR R4 Questionnaire + QuestionnaireResponse (JSON).
|
||||
* Not a full FHIR server — interchange export for completed/draft assessments.
|
||||
*/
|
||||
class FhirAssessmentExporter
|
||||
{
|
||||
public const FHIR_VERSION = '4.0.1';
|
||||
|
||||
/**
|
||||
* Bundle containing Questionnaire and QuestionnaireResponse.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function exportBundle(Assessment $assessment): array
|
||||
{
|
||||
$assessment->loadMissing(['template.questions', 'answers.question', 'patient', 'score']);
|
||||
|
||||
$questionnaire = $this->questionnaire($assessment);
|
||||
$response = $this->questionnaireResponse($assessment, $questionnaire['url']);
|
||||
|
||||
return [
|
||||
'resourceType' => 'Bundle',
|
||||
'type' => 'collection',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'meta' => [
|
||||
'tag' => [[
|
||||
'system' => 'https://care.ladill.com/fhir/tags',
|
||||
'code' => 'ladill-care-assessment-export',
|
||||
]],
|
||||
],
|
||||
'entry' => [
|
||||
['fullUrl' => $questionnaire['url'], 'resource' => $questionnaire],
|
||||
['fullUrl' => $response['id'] ? 'urn:uuid:'.$response['id'] : 'QuestionnaireResponse/'.$assessment->uuid, 'resource' => $response],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function questionnaire(Assessment $assessment): array
|
||||
{
|
||||
$template = $assessment->template;
|
||||
$url = 'https://care.ladill.com/fhir/Questionnaire/'.($template?->code ?? 'unknown').'/v'.($template?->version ?? 1);
|
||||
|
||||
$items = [];
|
||||
foreach ($template?->questions ?? [] as $question) {
|
||||
$items[] = $this->questionItem($question);
|
||||
}
|
||||
|
||||
return [
|
||||
'resourceType' => 'Questionnaire',
|
||||
'id' => ($template?->code ?? 'unknown').'-v'.($template?->version ?? 1),
|
||||
'url' => $url,
|
||||
'version' => (string) ($template?->version ?? 1),
|
||||
'name' => Str::studly($template?->code ?? 'Assessment'),
|
||||
'title' => $template?->name ?? 'Assessment',
|
||||
'status' => ($template?->is_active ?? true) ? 'active' : 'retired',
|
||||
'description' => $template?->description,
|
||||
'item' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function questionnaireResponse(Assessment $assessment, ?string $questionnaireUrl = null): array
|
||||
{
|
||||
$assessment->loadMissing(['template.questions', 'answers.question', 'patient', 'score']);
|
||||
$questionnaireUrl ??= $this->questionnaire($assessment)['url'];
|
||||
|
||||
$answersByQ = $assessment->answers->keyBy('question_id');
|
||||
$items = [];
|
||||
foreach ($assessment->template?->questions ?? [] as $question) {
|
||||
$answer = $answersByQ->get($question->id);
|
||||
if (! $answer) {
|
||||
continue;
|
||||
}
|
||||
$value = $answer->authoritativeValue($question->answer_type);
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'linkId' => $question->code,
|
||||
'text' => $question->label,
|
||||
'answer' => $this->fhirAnswers($question, $value),
|
||||
];
|
||||
}
|
||||
|
||||
$status = match ($assessment->status) {
|
||||
Assessment::STATUS_COMPLETED => 'completed',
|
||||
Assessment::STATUS_CANCELLED => 'entered-in-error',
|
||||
default => 'in-progress',
|
||||
};
|
||||
|
||||
$resource = [
|
||||
'resourceType' => 'QuestionnaireResponse',
|
||||
'id' => $assessment->uuid,
|
||||
'questionnaire' => $questionnaireUrl,
|
||||
'status' => $status,
|
||||
'authored' => ($assessment->assessed_at ?? $assessment->completed_at ?? $assessment->created_at)?->toIso8601String(),
|
||||
'subject' => [
|
||||
'reference' => 'Patient/'.($assessment->patient?->uuid ?? $assessment->patient_id),
|
||||
'display' => $assessment->patient?->fullName(),
|
||||
],
|
||||
'item' => $items,
|
||||
];
|
||||
|
||||
if ($assessment->score) {
|
||||
$resource['extension'] = [[
|
||||
'url' => 'https://care.ladill.com/fhir/StructureDefinition/assessment-score',
|
||||
'extension' => array_values(array_filter([
|
||||
['url' => 'total', 'valueDecimal' => (float) $assessment->score->total_score],
|
||||
$assessment->score->max_score !== null
|
||||
? ['url' => 'max', 'valueDecimal' => (float) $assessment->score->max_score]
|
||||
: null,
|
||||
$assessment->score->severity_label
|
||||
? ['url' => 'severity', 'valueString' => $assessment->score->severity_label]
|
||||
: null,
|
||||
['url' => 'templateCode', 'valueString' => $assessment->score->template_code],
|
||||
])),
|
||||
]];
|
||||
}
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function questionItem(AssessmentQuestion $question): array
|
||||
{
|
||||
$type = match ($question->answer_type) {
|
||||
AssessmentQuestion::TYPE_BOOLEAN => 'boolean',
|
||||
AssessmentQuestion::TYPE_INTEGER => 'integer',
|
||||
AssessmentQuestion::TYPE_NUMBER,
|
||||
AssessmentQuestion::TYPE_SCALE,
|
||||
AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||
AssessmentQuestion::TYPE_CALCULATED => 'decimal',
|
||||
AssessmentQuestion::TYPE_DATE => 'date',
|
||||
AssessmentQuestion::TYPE_DATETIME => 'dateTime',
|
||||
AssessmentQuestion::TYPE_MULTI_CHOICE => 'choice',
|
||||
AssessmentQuestion::TYPE_SINGLE_CHOICE => 'choice',
|
||||
default => 'string',
|
||||
};
|
||||
|
||||
$item = [
|
||||
'linkId' => $question->code,
|
||||
'text' => $question->label,
|
||||
'type' => $type,
|
||||
'required' => (bool) $question->is_required,
|
||||
];
|
||||
|
||||
if ($question->help_text) {
|
||||
$item['prefix'] = $question->help_text;
|
||||
}
|
||||
|
||||
$choices = $question->options['choices'] ?? null;
|
||||
if (is_array($choices) && $choices !== []) {
|
||||
$item['answerOption'] = array_map(function ($c) {
|
||||
$code = (string) ($c['code'] ?? '');
|
||||
$label = (string) ($c['label'] ?? $code);
|
||||
|
||||
return [
|
||||
'valueCoding' => [
|
||||
'code' => $code,
|
||||
'display' => $label,
|
||||
],
|
||||
];
|
||||
}, $choices);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
protected function fhirAnswers(AssessmentQuestion $question, mixed $value): array
|
||||
{
|
||||
return match ($question->answer_type) {
|
||||
AssessmentQuestion::TYPE_BOOLEAN => [['valueBoolean' => (bool) $value]],
|
||||
AssessmentQuestion::TYPE_INTEGER => [['valueInteger' => (int) $value]],
|
||||
AssessmentQuestion::TYPE_NUMBER,
|
||||
AssessmentQuestion::TYPE_SCALE,
|
||||
AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||
AssessmentQuestion::TYPE_CALCULATED => [['valueDecimal' => (float) $value]],
|
||||
AssessmentQuestion::TYPE_DATE => [['valueDate' => $value instanceof \Carbon\Carbon
|
||||
? $value->toDateString()
|
||||
: (string) $value]],
|
||||
AssessmentQuestion::TYPE_DATETIME => [['valueDateTime' => $value instanceof \Carbon\Carbon
|
||||
? $value->toIso8601String()
|
||||
: (string) $value]],
|
||||
AssessmentQuestion::TYPE_MULTI_CHOICE => collect(is_array($value) ? $value : [$value])
|
||||
->map(fn ($v) => ['valueString' => (string) $v])
|
||||
->values()
|
||||
->all(),
|
||||
AssessmentQuestion::TYPE_SINGLE_CHOICE => [['valueString' => (string) $value]],
|
||||
default => [['valueString' => is_scalar($value) ? (string) $value : json_encode($value)]],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Assessment;
|
||||
use App\Models\AssessmentScore;
|
||||
use App\Models\AssessmentTemplate;
|
||||
use App\Models\Patient;
|
||||
use App\Models\VitalSign;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Per-patient longitudinal trends (Layer 4) — free for all plans.
|
||||
* Org-wide multi-patient analytics remain Enterprise (KD-18).
|
||||
*/
|
||||
class OutcomeTrendService
|
||||
{
|
||||
public const OUTCOME_TEMPLATE_CODE = 'outcome_core';
|
||||
|
||||
/**
|
||||
* Completed outcome_core assessments newest-first with answer maps.
|
||||
*
|
||||
* @return Collection<int, array{assessment: Assessment, answers: array<string, mixed>, assessed_at: ?\Carbon\Carbon}>
|
||||
*/
|
||||
public function outcomeSeries(Patient $patient, string $ownerRef, ?int $branchScope = null, int $limit = 50): Collection
|
||||
{
|
||||
$template = AssessmentTemplate::currentSystemByCode(self::OUTCOME_TEMPLATE_CODE);
|
||||
|
||||
$query = Assessment::query()
|
||||
->owned($ownerRef)
|
||||
->where('patient_id', $patient->id)
|
||||
->where('status', Assessment::STATUS_COMPLETED)
|
||||
->with(['answers.question', 'template'])
|
||||
->orderByDesc('assessed_at')
|
||||
->orderByDesc('completed_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($template) {
|
||||
$query->where('template_id', $template->id);
|
||||
} else {
|
||||
$query->whereHas('template', fn ($q) => $q->where('code', self::OUTCOME_TEMPLATE_CODE));
|
||||
}
|
||||
|
||||
if ($branchScope !== null) {
|
||||
$query->where('branch_id', $branchScope);
|
||||
}
|
||||
|
||||
return $query->get()->map(function (Assessment $assessment) {
|
||||
$answers = [];
|
||||
foreach ($assessment->answers as $answer) {
|
||||
if ($answer->question) {
|
||||
$answers[$answer->question->code] = $answer->authoritativeValue($answer->question->answer_type);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'assessment' => $assessment,
|
||||
'answers' => $answers,
|
||||
'assessed_at' => $assessment->assessed_at ?? $assessment->completed_at,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent instrument scores for chartable disease templates.
|
||||
*
|
||||
* @return Collection<int, AssessmentScore>
|
||||
*/
|
||||
public function instrumentScores(
|
||||
Patient $patient,
|
||||
string $ownerRef,
|
||||
?int $branchScope = null,
|
||||
?string $templateCode = null,
|
||||
int $limit = 50,
|
||||
): Collection {
|
||||
$query = AssessmentScore::query()
|
||||
->owned($ownerRef)
|
||||
->whereHas('assessment', function ($q) use ($patient, $branchScope) {
|
||||
$q->where('patient_id', $patient->id)
|
||||
->where('status', Assessment::STATUS_COMPLETED)
|
||||
->when($branchScope !== null, fn ($qq) => $qq->where('branch_id', $branchScope));
|
||||
})
|
||||
->with(['assessment.template'])
|
||||
->orderByDesc('computed_at')
|
||||
->limit($limit);
|
||||
|
||||
if ($templateCode) {
|
||||
$query->where('template_code', $templateCode);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest vitals from consultations (typed path — not outcome form).
|
||||
*
|
||||
* @return Collection<int, VitalSign>
|
||||
*/
|
||||
public function recentVitals(Patient $patient, string $ownerRef, int $limit = 10): Collection
|
||||
{
|
||||
return VitalSign::query()
|
||||
->where('owner_ref', $ownerRef)
|
||||
->whereHas('consultation', fn ($q) => $q->where('patient_id', $patient->id))
|
||||
->orderByDesc('recorded_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sparkline-friendly series for a single outcome numeric code (oldest → newest).
|
||||
*
|
||||
* @return list<array{date: string, value: float|int|null}>
|
||||
*/
|
||||
public function numericOutcomeSeries(Patient $patient, string $ownerRef, string $questionCode, ?int $branchScope = null): array
|
||||
{
|
||||
$rows = $this->outcomeSeries($patient, $ownerRef, $branchScope)->reverse()->values();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$val = $row['answers'][$questionCode] ?? null;
|
||||
if ($val === null || $val === '') {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'date' => $row['assessed_at']?->format('Y-m-d') ?? '',
|
||||
'value' => is_numeric($val) ? $val + 0 : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\ClinicalPathway;
|
||||
use App\Models\Diagnosis;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Match persisted diagnosis rows to clinical pathways (v1 algorithm).
|
||||
*/
|
||||
class PathwayMatcher
|
||||
{
|
||||
/**
|
||||
* @param iterable<Diagnosis|array{code?: ?string, description?: ?string}> $diagnoses
|
||||
* @return Collection<int, array{
|
||||
* pathway: ClinicalPathway,
|
||||
* pathway_code: string,
|
||||
* pathway_name: string,
|
||||
* rank: int,
|
||||
* match_reason: string
|
||||
* }>
|
||||
*/
|
||||
public function match(iterable $diagnoses): Collection
|
||||
{
|
||||
$dxList = collect($diagnoses)->map(function ($d) {
|
||||
if ($d instanceof Diagnosis) {
|
||||
return [
|
||||
'code' => $d->code,
|
||||
'description' => $d->description,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => $d['code'] ?? null,
|
||||
'description' => $d['description'] ?? null,
|
||||
];
|
||||
})->filter(fn ($d) => filled($d['code']) || filled($d['description']));
|
||||
|
||||
if ($dxList->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$pathways = ClinicalPathway::query()
|
||||
->active()
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($pathways as $pathway) {
|
||||
$rules = $pathway->match_rules ?? [];
|
||||
$bestRank = null;
|
||||
$bestReason = null;
|
||||
|
||||
foreach ($dxList as $dx) {
|
||||
$result = $this->matchDiagnosis($dx, $rules);
|
||||
if ($result === null) {
|
||||
continue;
|
||||
}
|
||||
if ($bestRank === null || $result['rank'] > $bestRank) {
|
||||
$bestRank = $result['rank'];
|
||||
$bestReason = $result['reason'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestRank !== null) {
|
||||
$matches[] = [
|
||||
'pathway' => $pathway,
|
||||
'pathway_code' => $pathway->code,
|
||||
'pathway_name' => $pathway->name,
|
||||
'rank' => $bestRank,
|
||||
'match_reason' => $bestReason,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return collect($matches)
|
||||
->sort(function ($a, $b) {
|
||||
if ($a['rank'] !== $b['rank']) {
|
||||
return $b['rank'] <=> $a['rank'];
|
||||
}
|
||||
|
||||
return $a['pathway']->sort_order <=> $b['pathway']->sort_order;
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{code?: ?string, description?: ?string} $dx
|
||||
* @param array<string, mixed> $rules
|
||||
* @return array{rank: int, reason: string}|null
|
||||
*/
|
||||
protected function matchDiagnosis(array $dx, array $rules): ?array
|
||||
{
|
||||
$desc = $this->normalize((string) ($dx['description'] ?? ''));
|
||||
$codeStripped = $this->stripCode((string) ($dx['code'] ?? ''));
|
||||
|
||||
foreach ($rules['exclude_keywords'] ?? [] as $exclude) {
|
||||
$ex = $this->normalize((string) $exclude);
|
||||
if ($ex !== '' && $desc !== '' && str_contains($desc, $ex)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rules['icd_prefixes'] ?? [] as $prefix) {
|
||||
$p = $this->stripCode((string) $prefix);
|
||||
if ($p !== '' && $codeStripped !== '' && str_starts_with($codeStripped, $p)) {
|
||||
return ['rank' => 100, 'reason' => 'icd_prefix:'.$prefix];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rules['keywords'] ?? [] as $keyword) {
|
||||
$kw = $this->normalize((string) $keyword);
|
||||
if ($kw !== '' && $desc !== '' && str_contains($desc, $kw)) {
|
||||
return ['rank' => 50, 'reason' => 'keyword:'.$keyword];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function normalize(string $s): string
|
||||
{
|
||||
$s = mb_strtolower(trim($s));
|
||||
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
|
||||
|
||||
return $s;
|
||||
}
|
||||
|
||||
protected function stripCode(string $code): string
|
||||
{
|
||||
return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code) ?? '');
|
||||
}
|
||||
}
|
||||
@@ -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('; ');
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Models\Appointment;
|
||||
use App\Models\Assessment;
|
||||
use App\Models\AssessmentScore;
|
||||
use App\Models\Bill;
|
||||
use App\Models\Diagnosis;
|
||||
use App\Models\InvestigationRequest;
|
||||
use App\Models\Patient;
|
||||
use App\Models\PatientPathway;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Visit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -200,4 +203,101 @@ class ReportService
|
||||
->limit(20)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Org-level assessment analytics (Enterprise / assessment_analytics).
|
||||
*
|
||||
* @return array{
|
||||
* summary: array<string, int>,
|
||||
* by_template: Collection<int, object>,
|
||||
* by_pathway: Collection<int, object>,
|
||||
* score_averages: Collection<int, object>
|
||||
* }
|
||||
*/
|
||||
public function assessmentsReport(
|
||||
string $ownerRef,
|
||||
int $organizationId,
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
?int $branchId = null,
|
||||
): array {
|
||||
$base = Assessment::query()
|
||||
->from('care_assessments')
|
||||
->where('care_assessments.owner_ref', $ownerRef)
|
||||
->where('care_assessments.organization_id', $organizationId)
|
||||
->when($branchId, fn ($q) => $q->where('care_assessments.branch_id', $branchId))
|
||||
->whereBetween('care_assessments.created_at', [$from, $to])
|
||||
->whereNull('care_assessments.deleted_at');
|
||||
|
||||
$summary = [
|
||||
'total_started' => (clone $base)->count(),
|
||||
'completed' => (clone $base)->where('care_assessments.status', Assessment::STATUS_COMPLETED)->count(),
|
||||
'draft' => (clone $base)->where('care_assessments.status', Assessment::STATUS_DRAFT)->count(),
|
||||
'cancelled' => (clone $base)->where('care_assessments.status', Assessment::STATUS_CANCELLED)->count(),
|
||||
'patients_with_assessments' => (clone $base)->distinct('care_assessments.patient_id')->count('care_assessments.patient_id'),
|
||||
'pathways_activated' => PatientPathway::query()
|
||||
->where('care_patient_pathways.owner_ref', $ownerRef)
|
||||
->where('care_patient_pathways.organization_id', $organizationId)
|
||||
->whereBetween('care_patient_pathways.activated_at', [$from, $to])
|
||||
->whereNull('care_patient_pathways.deleted_at')
|
||||
->count(),
|
||||
];
|
||||
|
||||
$byTemplate = (clone $base)
|
||||
->join('care_assessment_templates', 'care_assessments.template_id', '=', 'care_assessment_templates.id')
|
||||
->select(
|
||||
'care_assessment_templates.code as template_code',
|
||||
'care_assessment_templates.name as template_name',
|
||||
DB::raw('count(*) as total'),
|
||||
DB::raw("sum(case when care_assessments.status = 'completed' then 1 else 0 end) as completed"),
|
||||
)
|
||||
->groupBy('care_assessment_templates.code', 'care_assessment_templates.name')
|
||||
->orderByDesc('total')
|
||||
->limit(30)
|
||||
->get();
|
||||
|
||||
$byPathway = PatientPathway::query()
|
||||
->from('care_patient_pathways')
|
||||
->where('care_patient_pathways.owner_ref', $ownerRef)
|
||||
->where('care_patient_pathways.organization_id', $organizationId)
|
||||
->whereBetween('care_patient_pathways.activated_at', [$from, $to])
|
||||
->whereNull('care_patient_pathways.deleted_at')
|
||||
->join('care_clinical_pathways', 'care_patient_pathways.pathway_id', '=', 'care_clinical_pathways.id')
|
||||
->select(
|
||||
'care_clinical_pathways.code as pathway_code',
|
||||
'care_clinical_pathways.name as pathway_name',
|
||||
DB::raw('count(*) as total'),
|
||||
DB::raw("sum(case when care_patient_pathways.status = 'active' then 1 else 0 end) as still_active"),
|
||||
)
|
||||
->groupBy('care_clinical_pathways.code', 'care_clinical_pathways.name')
|
||||
->orderByDesc('total')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$scoreAverages = AssessmentScore::query()
|
||||
->from('care_assessment_scores')
|
||||
->where('care_assessment_scores.owner_ref', $ownerRef)
|
||||
->whereHas('assessment', function (Builder $q) use ($organizationId, $branchId, $from, $to) {
|
||||
$q->where('care_assessments.organization_id', $organizationId)
|
||||
->when($branchId, fn ($qq) => $qq->where('care_assessments.branch_id', $branchId))
|
||||
->whereBetween('care_assessments.completed_at', [$from, $to]);
|
||||
})
|
||||
->select(
|
||||
'care_assessment_scores.template_code',
|
||||
DB::raw('count(*) as completions'),
|
||||
DB::raw('avg(care_assessment_scores.total_score) as avg_total'),
|
||||
DB::raw('avg(care_assessment_scores.max_score) as avg_max'),
|
||||
)
|
||||
->groupBy('care_assessment_scores.template_code')
|
||||
->orderByDesc('completions')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'by_template' => $byTemplate,
|
||||
'by_pathway' => $byPathway,
|
||||
'score_averages' => $scoreAverages,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Care;
|
||||
|
||||
use App\Contracts\Care\ScoresAssessment;
|
||||
use App\Models\Assessment;
|
||||
use App\Models\AssessmentQuestion;
|
||||
use App\Models\AssessmentScore;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class ScoringService
|
||||
{
|
||||
/**
|
||||
* Preview scores without persisting.
|
||||
*
|
||||
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||
*/
|
||||
public function preview(Assessment $assessment): array
|
||||
{
|
||||
$assessment->loadMissing(['template.questions', 'answers.question']);
|
||||
|
||||
return $this->compute($assessment);
|
||||
}
|
||||
|
||||
public function materialize(Assessment $assessment): AssessmentScore
|
||||
{
|
||||
$assessment->loadMissing(['template.questions', 'answers.question']);
|
||||
|
||||
try {
|
||||
$result = $this->compute($assessment);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('care.scoring.failure', [
|
||||
'template_code' => $assessment->template?->code,
|
||||
'assessment_uuid' => $assessment->uuid,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return AssessmentScore::updateOrCreate(
|
||||
['assessment_id' => $assessment->id],
|
||||
[
|
||||
'owner_ref' => $assessment->owner_ref,
|
||||
'template_code' => $assessment->template->code,
|
||||
'total_score' => $result['total'],
|
||||
'max_score' => $result['max'],
|
||||
'subscores' => $result['subscores'] !== [] ? $result['subscores'] : null,
|
||||
'severity_label' => $result['severity_label'],
|
||||
'computed_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this template requires score materialization on complete.
|
||||
*/
|
||||
public function requiresScore(Assessment $assessment): bool
|
||||
{
|
||||
$strategy = $assessment->template?->scoring_strategy;
|
||||
|
||||
return filled($strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||
*/
|
||||
protected function compute(Assessment $assessment): array
|
||||
{
|
||||
$strategy = $assessment->template?->scoring_strategy;
|
||||
|
||||
if (! filled($strategy)) {
|
||||
return [
|
||||
'total' => null,
|
||||
'max' => null,
|
||||
'subscores' => [],
|
||||
'severity_label' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if (str_starts_with($strategy, 'custom:')) {
|
||||
return $this->customScore($assessment, substr($strategy, 7));
|
||||
}
|
||||
|
||||
return match ($strategy) {
|
||||
'sum_items' => $this->sumItems($assessment),
|
||||
'single_value' => $this->singleValue($assessment),
|
||||
default => throw ValidationException::withMessages([
|
||||
'scoring' => "Unknown scoring strategy [{$strategy}].",
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||
*/
|
||||
protected function sumItems(Assessment $assessment): array
|
||||
{
|
||||
$scoreItems = $assessment->template->questions
|
||||
->where('answer_type', AssessmentQuestion::TYPE_SCORE_ITEM);
|
||||
|
||||
if ($scoreItems->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'scoring' => 'sum_items strategy requires score_item questions.',
|
||||
]);
|
||||
}
|
||||
|
||||
$answersByQuestion = $assessment->answers->keyBy('question_id');
|
||||
$total = 0.0;
|
||||
$max = 0.0;
|
||||
$subscores = [];
|
||||
$missing = [];
|
||||
|
||||
foreach ($scoreItems as $question) {
|
||||
$answer = $answersByQuestion->get($question->id);
|
||||
$value = $answer?->value_number;
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
$missing[] = $question->code;
|
||||
continue;
|
||||
}
|
||||
|
||||
$num = (float) $value;
|
||||
$total += $num;
|
||||
$itemMax = $this->itemMaxScore($question);
|
||||
$max += $itemMax;
|
||||
|
||||
$key = $question->score_key ?: $question->code;
|
||||
$subscores[$key] = ($subscores[$key] ?? 0) + $num;
|
||||
}
|
||||
|
||||
if ($missing !== []) {
|
||||
throw ValidationException::withMessages(
|
||||
collect($missing)->mapWithKeys(
|
||||
fn ($code) => ["answers.{$code}" => 'Score item is required for scoring.']
|
||||
)->all()
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'max' => $max > 0 ? $max : null,
|
||||
'subscores' => $subscores,
|
||||
'severity_label' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||
*/
|
||||
protected function singleValue(Assessment $assessment): array
|
||||
{
|
||||
$scored = $assessment->template->questions->filter(
|
||||
fn (AssessmentQuestion $q) => in_array($q->answer_type, [
|
||||
AssessmentQuestion::TYPE_SCORE_ITEM,
|
||||
AssessmentQuestion::TYPE_SCALE,
|
||||
AssessmentQuestion::TYPE_NUMBER,
|
||||
], true)
|
||||
);
|
||||
|
||||
if ($scored->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'scoring' => 'single_value strategy requires a scored question.',
|
||||
]);
|
||||
}
|
||||
|
||||
$question = $scored->sortBy('sort_order')->first();
|
||||
$answer = $assessment->answers->firstWhere('question_id', $question->id);
|
||||
if ($answer?->value_number === null || $answer->value_number === '') {
|
||||
throw ValidationException::withMessages([
|
||||
"answers.{$question->code}" => 'Score is required for scoring.',
|
||||
]);
|
||||
}
|
||||
|
||||
$total = (float) $answer->value_number;
|
||||
$max = $this->itemMaxScore($question);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'max' => $max > 0 ? $max : null,
|
||||
'subscores' => [$question->score_key ?: $question->code => $total],
|
||||
'severity_label' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: ?float, max: ?float, subscores: array<string, float|int>, severity_label: ?string}
|
||||
*/
|
||||
protected function customScore(Assessment $assessment, string $handlerName): array
|
||||
{
|
||||
if (! preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $handlerName)) {
|
||||
throw new InvalidArgumentException('Invalid custom scoring handler name.');
|
||||
}
|
||||
|
||||
$class = 'App\\Services\\Care\\Scoring\\'.$handlerName;
|
||||
if (! class_exists($class)) {
|
||||
throw ValidationException::withMessages([
|
||||
'scoring' => "Scoring handler [{$handlerName}] is not registered.",
|
||||
]);
|
||||
}
|
||||
|
||||
$handler = app($class);
|
||||
if (! $handler instanceof ScoresAssessment) {
|
||||
throw new InvalidArgumentException("Handler [{$handlerName}] must implement ScoresAssessment.");
|
||||
}
|
||||
|
||||
$result = $handler->score($assessment);
|
||||
|
||||
return [
|
||||
'total' => isset($result['total']) ? (float) $result['total'] : null,
|
||||
'max' => isset($result['max']) ? (float) $result['max'] : null,
|
||||
'subscores' => is_array($result['subscores'] ?? null) ? $result['subscores'] : [],
|
||||
'severity_label' => $result['severity_label'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
protected function itemMaxScore(AssessmentQuestion $question): float
|
||||
{
|
||||
$options = $question->options ?? [];
|
||||
if (isset($options['max']) && is_numeric($options['max'])) {
|
||||
return (float) $options['max'];
|
||||
}
|
||||
|
||||
$choices = $options['choices'] ?? [];
|
||||
$scores = [];
|
||||
foreach ($choices as $choice) {
|
||||
if (isset($choice['score']) && is_numeric($choice['score'])) {
|
||||
$scores[] = (float) $choice['score'];
|
||||
}
|
||||
}
|
||||
|
||||
return $scores !== [] ? max($scores) : 0.0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user