'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 $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 $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 */ public function defaultCaptureRoles(string $category): array { return match ($category) { AssessmentTemplate::CATEGORY_DISEASE => [ 'doctor', 'general_physician', 'emergency_physician', 'surgeon', 'pediatrician', 'oncologist', 'psychiatrist', 'dentist', 'pathologist', ], default => [ 'doctor', 'general_physician', 'emergency_physician', 'surgeon', 'pediatrician', 'oncologist', 'psychiatrist', 'dentist', 'pathologist', 'nurse', 'ed_nurse', 'theatre_nurse', 'dialysis_nurse', 'midwife', 'physiotherapist', 'ambulance_staff', 'radiographer', ], }; } /** * 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 */ 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; } }