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