Files
ladill-care/app/Services/Care/ScoringService.php
T
isaacclad 2ce4bc8993
Deploy Ladill Care / deploy (push) Successful in 1m26s
feat(assessments): layered clinical assessment engine end-to-end
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.
2026-07-16 22:58:09 +00:00

236 lines
7.8 KiB
PHP

<?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;
}
}