Deploy Ladill Care / deploy (push) Successful in 55s
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>
268 lines
8.7 KiB
PHP
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;
|
|
}
|
|
}
|