Files
ladill-care/app/Services/Care/ScoringService.php
T
isaaccladandCursor 9eb6c21828
Deploy Ladill Care / deploy (push) Successful in 55s
Add nursing assessment packs, ward board, and nurse performance KPIs.
Seeds NEWS2/Braden/Morse/pain packs with visit vitals and a unit dashboard for MAR, assessments, notes, and handover activity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:32:52 +00:00

268 lines
8.7 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:')) {
$result = $this->customScore($assessment, substr($strategy, 7));
} else {
$result = match ($strategy) {
'sum_items' => $this->sumItems($assessment),
'single_value' => $this->singleValue($assessment),
default => throw ValidationException::withMessages([
'scoring' => "Unknown scoring strategy [{$strategy}].",
]),
};
}
$result['severity_label'] = $result['severity_label']
?? $this->severityFromBands($assessment->template?->meta, $result['total'] ?? null);
return $result;
}
/**
* @param array<string, mixed>|null $meta
*/
protected function severityFromBands(?array $meta, mixed $total): ?string
{
if ($total === null || ! is_numeric($total)) {
return null;
}
$bands = $meta['severity_bands'] ?? null;
if (! is_array($bands) || $bands === []) {
return null;
}
$score = (float) $total;
foreach ($bands as $band) {
if (! is_array($band) || ! isset($band['max'], $band['label'])) {
continue;
}
if ($score <= (float) $band['max']) {
return (string) $band['label'];
}
}
return null;
}
/**
* @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;
}
}