Files
ladill-care/app/Services/Care/OutcomeTrendService.php
T
isaacclad 2ce4bc8993
Deploy Ladill Care / deploy (push) Successful in 1m26s
feat(assessments): layered clinical assessment engine end-to-end
Add a template-driven assessment system with universal intake, clinical
pathways, disease instruments (stroke MVP + extended, diabetes, and ten
specialty packs), scoring, patient outcome trends, REST/API + FHIR
export, and Enterprise org-level assessment analytics. Seed packs and
design/licensing docs ship for deploy and pre-GA review.
2026-07-16 22:58:09 +00:00

133 lines
4.3 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\Assessment;
use App\Models\AssessmentScore;
use App\Models\AssessmentTemplate;
use App\Models\Patient;
use App\Models\VitalSign;
use Illuminate\Support\Collection;
/**
* Per-patient longitudinal trends (Layer 4) — free for all plans.
* Org-wide multi-patient analytics remain Enterprise (KD-18).
*/
class OutcomeTrendService
{
public const OUTCOME_TEMPLATE_CODE = 'outcome_core';
/**
* Completed outcome_core assessments newest-first with answer maps.
*
* @return Collection<int, array{assessment: Assessment, answers: array<string, mixed>, assessed_at: ?\Carbon\Carbon}>
*/
public function outcomeSeries(Patient $patient, string $ownerRef, ?int $branchScope = null, int $limit = 50): Collection
{
$template = AssessmentTemplate::currentSystemByCode(self::OUTCOME_TEMPLATE_CODE);
$query = Assessment::query()
->owned($ownerRef)
->where('patient_id', $patient->id)
->where('status', Assessment::STATUS_COMPLETED)
->with(['answers.question', 'template'])
->orderByDesc('assessed_at')
->orderByDesc('completed_at')
->limit($limit);
if ($template) {
$query->where('template_id', $template->id);
} else {
$query->whereHas('template', fn ($q) => $q->where('code', self::OUTCOME_TEMPLATE_CODE));
}
if ($branchScope !== null) {
$query->where('branch_id', $branchScope);
}
return $query->get()->map(function (Assessment $assessment) {
$answers = [];
foreach ($assessment->answers as $answer) {
if ($answer->question) {
$answers[$answer->question->code] = $answer->authoritativeValue($answer->question->answer_type);
}
}
return [
'assessment' => $assessment,
'answers' => $answers,
'assessed_at' => $assessment->assessed_at ?? $assessment->completed_at,
];
});
}
/**
* Recent instrument scores for chartable disease templates.
*
* @return Collection<int, AssessmentScore>
*/
public function instrumentScores(
Patient $patient,
string $ownerRef,
?int $branchScope = null,
?string $templateCode = null,
int $limit = 50,
): Collection {
$query = AssessmentScore::query()
->owned($ownerRef)
->whereHas('assessment', function ($q) use ($patient, $branchScope) {
$q->where('patient_id', $patient->id)
->where('status', Assessment::STATUS_COMPLETED)
->when($branchScope !== null, fn ($qq) => $qq->where('branch_id', $branchScope));
})
->with(['assessment.template'])
->orderByDesc('computed_at')
->limit($limit);
if ($templateCode) {
$query->where('template_code', $templateCode);
}
return $query->get();
}
/**
* Latest vitals from consultations (typed path — not outcome form).
*
* @return Collection<int, VitalSign>
*/
public function recentVitals(Patient $patient, string $ownerRef, int $limit = 10): Collection
{
return VitalSign::query()
->where('owner_ref', $ownerRef)
->whereHas('consultation', fn ($q) => $q->where('patient_id', $patient->id))
->orderByDesc('recorded_at')
->limit($limit)
->get();
}
/**
* Sparkline-friendly series for a single outcome numeric code (oldest → newest).
*
* @return list<array{date: string, value: float|int|null}>
*/
public function numericOutcomeSeries(Patient $patient, string $ownerRef, string $questionCode, ?int $branchScope = null): array
{
$rows = $this->outcomeSeries($patient, $ownerRef, $branchScope)->reverse()->values();
$out = [];
foreach ($rows as $row) {
$val = $row['answers'][$questionCode] ?? null;
if ($val === null || $val === '') {
continue;
}
$out[] = [
'date' => $row['assessed_at']?->format('Y-m-d') ?? '',
'value' => is_numeric($val) ? $val + 0 : null,
];
}
return $out;
}
}