Deploy Ladill Care / deploy (push) Successful in 57s
Replace broad doctor/nurse specialty access with granular roles and primary apps, permission inheritance for lab/BB managers, and cannot-rules for discharge, lab approve, and cashier vs billing officer. Co-authored-by: Cursor <cursoragent@cursor.com>
579 lines
20 KiB
PHP
579 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Care;
|
|
|
|
use App\Models\Assessment;
|
|
use App\Models\AssessmentAnswer;
|
|
use App\Models\AssessmentQuestion;
|
|
use App\Models\AssessmentTemplate;
|
|
use App\Models\Consultation;
|
|
use App\Models\Member;
|
|
use App\Models\Patient;
|
|
use App\Models\Visit;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
class AssessmentService
|
|
{
|
|
public function __construct(
|
|
protected ScoringService $scoring,
|
|
) {}
|
|
|
|
/**
|
|
* Start a draft assessment for the current system template by code.
|
|
* Idempotent: returns existing draft for the same patient/template/(consultation).
|
|
*
|
|
* @param array{
|
|
* consultation?: ?Consultation,
|
|
* visit?: ?Visit,
|
|
* patient_pathway?: ?\App\Models\PatientPathway,
|
|
* practitioner_id?: ?int,
|
|
* actor?: ?string,
|
|
* } $context
|
|
*/
|
|
public function start(
|
|
Patient $patient,
|
|
string $templateCode,
|
|
string $ownerRef,
|
|
?Member $member,
|
|
array $context = [],
|
|
): Assessment {
|
|
$template = AssessmentTemplate::currentSystemByCode($templateCode);
|
|
|
|
if (! $template) {
|
|
throw ValidationException::withMessages([
|
|
'template_code' => '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<string, mixed> $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<string, mixed> $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<string>
|
|
*/
|
|
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<string>
|
|
*/
|
|
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;
|
|
}
|
|
}
|