feat(assessments): layered clinical assessment engine end-to-end
Deploy Ladill Care / deploy (push) Successful in 1m26s

Add a template-driven assessment system with universal intake, clinical
pathways, disease instruments (stroke MVP + extended, diabetes, and ten
specialty packs), scoring, patient outcome trends, REST/API + FHIR
export, and Enterprise org-level assessment analytics. Seed packs and
design/licensing docs ship for deploy and pre-GA review.
This commit is contained in:
isaacclad
2026-07-16 22:58:09 +00:00
parent 8896088425
commit 2ce4bc8993
94 changed files with 13430 additions and 56 deletions
+570
View File
@@ -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;
}
}
+40
View File
@@ -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();
}
}
+3
View File
@@ -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)]],
};
}
}
+132
View File
@@ -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;
}
}
+135
View File
@@ -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) ?? '');
}
}
+199
View File
@@ -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('; ');
}
}
+100
View File
@@ -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,
];
}
}
+235
View File
@@ -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;
}
}