feat(assessments): layered clinical assessment engine end-to-end
Deploy Ladill Care / deploy (push) Successful in 1m26s

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.
This commit is contained in:
isaacclad
2026-07-16 22:58:09 +00:00
parent 8896088425
commit 2ce4bc8993
94 changed files with 13430 additions and 56 deletions
@@ -0,0 +1,131 @@
<?php
namespace Database\Seeders;
use App\Models\AssessmentQuestion;
use App\Models\AssessmentTemplate;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use RuntimeException;
/**
* Loads platform assessment content packs from database/data/assessments/*.json.
* Upserts by (code, version); replaces questions for that template version.
*/
class AssessmentTemplateSeeder extends Seeder
{
public function run(): void
{
$dir = database_path('data/assessments');
if (! is_dir($dir)) {
$this->command?->warn("No assessment packs directory at {$dir}");
return;
}
$files = collect(File::files($dir))
->filter(fn ($file) => str_ends_with(strtolower($file->getFilename()), '.json'))
->sortBy(fn ($file) => $file->getFilename())
->values();
if ($files->isEmpty()) {
$this->command?->warn('No assessment JSON packs found.');
return;
}
foreach ($files as $file) {
$this->seedPack($file->getPathname());
}
}
public function seedPack(string $path): AssessmentTemplate
{
$raw = File::get($path);
$data = json_decode($raw, true);
if (! is_array($data) || ! isset($data['template'], $data['questions'])) {
throw new RuntimeException("Invalid assessment pack: {$path}");
}
$t = $data['template'];
foreach (['code', 'name', 'category', 'version'] as $required) {
if (! array_key_exists($required, $t)) {
throw new RuntimeException("Assessment pack missing template.{$required}: {$path}");
}
}
return DB::transaction(function () use ($t, $data, $path) {
$template = AssessmentTemplate::withTrashed()
->whereNull('organization_id')
->where('code', $t['code'])
->where('version', (int) $t['version'])
->first();
$attributes = [
'organization_id' => null,
'code' => $t['code'],
'name' => $t['name'],
'category' => $t['category'],
'description' => $t['description'] ?? null,
'version' => (int) $t['version'],
'is_current' => (bool) ($t['is_current'] ?? true),
'is_active' => (bool) ($t['is_active'] ?? true),
'scoring_strategy' => $t['scoring_strategy'] ?? null,
'meta' => $t['meta'] ?? null,
'deleted_at' => null,
];
if ($template) {
if ($template->trashed()) {
$template->restore();
}
$template->fill(collect($attributes)->except('deleted_at')->all())->save();
} else {
$template = AssessmentTemplate::create(collect($attributes)->except('deleted_at')->all());
}
if ($template->is_current) {
AssessmentTemplate::query()
->whereNull('organization_id')
->where('code', $template->code)
->where('id', '!=', $template->id)
->where('is_current', true)
->update(['is_current' => false]);
}
// Replace questions for this template version (stable codes within version).
AssessmentQuestion::query()->where('template_id', $template->id)->delete();
$sort = 0;
foreach ($data['questions'] as $q) {
if (empty($q['code']) || empty($q['label']) || empty($q['answer_type'])) {
throw new RuntimeException("Invalid question in pack {$path}");
}
$sort++;
AssessmentQuestion::create([
'template_id' => $template->id,
'code' => $q['code'],
'section' => $q['section'] ?? null,
'label' => $q['label'],
'help_text' => $q['help_text'] ?? null,
'answer_type' => $q['answer_type'],
'options' => $q['options'] ?? null,
'is_required' => (bool) ($q['is_required'] ?? false),
'sort_order' => (int) ($q['sort_order'] ?? $sort),
'score_key' => $q['score_key'] ?? null,
'validation' => $q['validation'] ?? null,
]);
}
$this->command?->info(sprintf(
'Seeded assessment pack %s v%d (%d questions)',
$template->code,
$template->version,
count($data['questions']),
));
return $template->fresh('questions');
});
}
}
@@ -0,0 +1,95 @@
<?php
namespace Database\Seeders;
use App\Models\ClinicalPathway;
use App\Models\PathwayTemplate;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use RuntimeException;
/**
* Loads platform pathway packs from database/data/pathways/*.json.
* Bindings store template_code only (resolve is_current at assessment start).
*/
class ClinicalPathwaySeeder extends Seeder
{
public function run(): void
{
$dir = database_path('data/pathways');
if (! is_dir($dir)) {
$this->command?->warn("No pathway packs directory at {$dir}");
return;
}
$files = collect(File::files($dir))
->filter(fn ($file) => str_ends_with(strtolower($file->getFilename()), '.json'))
->sortBy(fn ($file) => $file->getFilename())
->values();
foreach ($files as $file) {
$this->seedPack($file->getPathname());
}
}
public function seedPack(string $path): ClinicalPathway
{
$data = json_decode(File::get($path), true);
if (! is_array($data) || ! isset($data['pathway'])) {
throw new RuntimeException("Invalid pathway pack: {$path}");
}
$p = $data['pathway'];
if (empty($p['code']) || empty($p['name'])) {
throw new RuntimeException("Pathway pack missing code/name: {$path}");
}
return DB::transaction(function () use ($p, $data, $path) {
$pathway = ClinicalPathway::withTrashed()->where('code', $p['code'])->first();
$attributes = [
'code' => $p['code'],
'name' => $p['name'],
'description' => $p['description'] ?? null,
'match_rules' => $p['match_rules'] ?? null,
'is_active' => (bool) ($p['is_active'] ?? true),
'sort_order' => (int) ($p['sort_order'] ?? 0),
'meta' => $p['meta'] ?? null,
];
if ($pathway) {
if ($pathway->trashed()) {
$pathway->restore();
}
$pathway->fill($attributes)->save();
} else {
$pathway = ClinicalPathway::create($attributes);
}
PathwayTemplate::query()->where('pathway_id', $pathway->id)->delete();
foreach ($data['templates'] ?? [] as $i => $binding) {
if (empty($binding['template_code'])) {
throw new RuntimeException("Pathway pack binding missing template_code: {$path}");
}
PathwayTemplate::create([
'pathway_id' => $pathway->id,
'template_code' => $binding['template_code'],
'is_required_on_activation' => (bool) ($binding['is_required_on_activation'] ?? false),
'phase' => $binding['phase'] ?? PathwayTemplate::PHASE_ANY,
'sort_order' => (int) ($binding['sort_order'] ?? ($i + 1)),
]);
}
$this->command?->info(sprintf(
'Seeded pathway %s (%d template bindings)',
$pathway->code,
count($data['templates'] ?? []),
));
return $pathway->fresh('templates');
});
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
AssessmentTemplateSeeder::class,
ClinicalPathwaySeeder::class,
]);
}
}