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.
136 lines
4.0 KiB
PHP
136 lines
4.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Care;
|
|
|
|
use App\Models\ClinicalPathway;
|
|
use App\Models\Diagnosis;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* Match persisted diagnosis rows to clinical pathways (v1 algorithm).
|
|
*/
|
|
class PathwayMatcher
|
|
{
|
|
/**
|
|
* @param iterable<Diagnosis|array{code?: ?string, description?: ?string}> $diagnoses
|
|
* @return Collection<int, array{
|
|
* pathway: ClinicalPathway,
|
|
* pathway_code: string,
|
|
* pathway_name: string,
|
|
* rank: int,
|
|
* match_reason: string
|
|
* }>
|
|
*/
|
|
public function match(iterable $diagnoses): Collection
|
|
{
|
|
$dxList = collect($diagnoses)->map(function ($d) {
|
|
if ($d instanceof Diagnosis) {
|
|
return [
|
|
'code' => $d->code,
|
|
'description' => $d->description,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'code' => $d['code'] ?? null,
|
|
'description' => $d['description'] ?? null,
|
|
];
|
|
})->filter(fn ($d) => filled($d['code']) || filled($d['description']));
|
|
|
|
if ($dxList->isEmpty()) {
|
|
return collect();
|
|
}
|
|
|
|
$pathways = ClinicalPathway::query()
|
|
->active()
|
|
->orderBy('sort_order')
|
|
->get();
|
|
|
|
$matches = [];
|
|
|
|
foreach ($pathways as $pathway) {
|
|
$rules = $pathway->match_rules ?? [];
|
|
$bestRank = null;
|
|
$bestReason = null;
|
|
|
|
foreach ($dxList as $dx) {
|
|
$result = $this->matchDiagnosis($dx, $rules);
|
|
if ($result === null) {
|
|
continue;
|
|
}
|
|
if ($bestRank === null || $result['rank'] > $bestRank) {
|
|
$bestRank = $result['rank'];
|
|
$bestReason = $result['reason'];
|
|
}
|
|
}
|
|
|
|
if ($bestRank !== null) {
|
|
$matches[] = [
|
|
'pathway' => $pathway,
|
|
'pathway_code' => $pathway->code,
|
|
'pathway_name' => $pathway->name,
|
|
'rank' => $bestRank,
|
|
'match_reason' => $bestReason,
|
|
];
|
|
}
|
|
}
|
|
|
|
return collect($matches)
|
|
->sort(function ($a, $b) {
|
|
if ($a['rank'] !== $b['rank']) {
|
|
return $b['rank'] <=> $a['rank'];
|
|
}
|
|
|
|
return $a['pathway']->sort_order <=> $b['pathway']->sort_order;
|
|
})
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* @param array{code?: ?string, description?: ?string} $dx
|
|
* @param array<string, mixed> $rules
|
|
* @return array{rank: int, reason: string}|null
|
|
*/
|
|
protected function matchDiagnosis(array $dx, array $rules): ?array
|
|
{
|
|
$desc = $this->normalize((string) ($dx['description'] ?? ''));
|
|
$codeStripped = $this->stripCode((string) ($dx['code'] ?? ''));
|
|
|
|
foreach ($rules['exclude_keywords'] ?? [] as $exclude) {
|
|
$ex = $this->normalize((string) $exclude);
|
|
if ($ex !== '' && $desc !== '' && str_contains($desc, $ex)) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
foreach ($rules['icd_prefixes'] ?? [] as $prefix) {
|
|
$p = $this->stripCode((string) $prefix);
|
|
if ($p !== '' && $codeStripped !== '' && str_starts_with($codeStripped, $p)) {
|
|
return ['rank' => 100, 'reason' => 'icd_prefix:'.$prefix];
|
|
}
|
|
}
|
|
|
|
foreach ($rules['keywords'] ?? [] as $keyword) {
|
|
$kw = $this->normalize((string) $keyword);
|
|
if ($kw !== '' && $desc !== '' && str_contains($desc, $kw)) {
|
|
return ['rank' => 50, 'reason' => 'keyword:'.$keyword];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected function normalize(string $s): string
|
|
{
|
|
$s = mb_strtolower(trim($s));
|
|
$s = preg_replace('/\s+/u', ' ', $s) ?? $s;
|
|
|
|
return $s;
|
|
}
|
|
|
|
protected function stripCode(string $code): string
|
|
{
|
|
return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code) ?? '');
|
|
}
|
|
}
|