This commit is contained in:
Masroor Ehsan 2025-01-22 17:08:17 +06:00
parent 3f308597c7
commit a75833f77a
12 changed files with 255 additions and 83 deletions

View File

@ -4,6 +4,7 @@
enum MatchCondition: string
{
case And = 'AND';
case Or = 'OR';
case ALL = 'ALL';
case ANY = 'ANY';
case NONE = 'NONE';
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use App\Models\Traits\Active;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
class AssignmentPanel extends BaseModel
{
use Active;
public function radiologists(): HasManyThrough
{
return $this->hasManyThrough(User::class, AssignmentPanelRadiologist::class);
}
}

View File

@ -0,0 +1,5 @@
<?php
namespace App\Models;
class AssignmentPanelRadiologist extends BaseModel {}

View File

@ -6,6 +6,7 @@
use App\Models\Traits\Active;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class DicomRoutingRule extends BaseModel
{
@ -26,6 +27,16 @@ public function facility(): BelongsTo
return $this->belongsTo(Facility::class);
}
public function panel(): HasOne
{
return $this->hasOne(AssignmentPanel::class);
}
public function radiologist(): HasOne
{
return $this->hasOne(User::class, 'radiologist_id');
}
protected function casts(): array
{
return [

View File

@ -14,6 +14,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@ -173,6 +174,11 @@ public function canAccessPanel(Panel $panel): bool
return $this->isAdmin();
}
public function panels(): HasManyThrough
{
return $this->hasManyThrough(AssignmentPanel::class, AssignmentPanelRadiologist::class);
}
/**
* Get the attributes that should be cast.
*

View File

@ -108,7 +108,7 @@ public function fetchStudyDetails(string $orthanc_uuid): ?array
public function transformData(mixed $orthanc_src): array
{
$inst_name = data_get($orthanc_src, 'MainDicomTags.InstitutionName');
$inst_id = DicomStudyRouter::map($inst_name);
$inst_id = DicomStudyRouter::matchStudy($inst_name);
$patient_name = data_get($orthanc_src, 'PatientMainDicomTags.PatientName');

View File

@ -2,45 +2,137 @@
namespace App\Services\StudyRouter;
use App\Domain\ACL\Role;
use App\Domain\Rule\MatchCondition;
use App\Domain\Rule\MatchMode;
use App\Models\AssignmentPanel;
use App\Models\DicomRoutingRule;
use App\Services\ContentMatcher;
use App\Models\DicomRuleCondition;
use App\Models\Institute;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class DicomStudyRouter
{
private static ?Collection $rules;
private static ?Collection $activeRads;
private static int $catchAll = -1;
const int CACHE_TTL = 15;
/**
* @return array<int, ?int>
*/
public static function map(?string $input): array
private static function fallbackRoute(): array
{
if (is_null(self::$rules)) {
self::$rules = Cache::remember('dicom.routers',
now()->addMinutes(15),
fn () => DicomRoutingRule::active()->with('conditions')->get()
);
self::$catchAll = DB::table('institutes')
->where('name', 'Catch-all')
->first('id')
->id;
return [
'institute_id' => self::$catchAll,
'facility_id' => null,
'rule_id' => null,
'radiologists' => null,
];
}
public static function matchStudy(array $dicomHeaders): array
{
self::initialize();
if (blank($dicomHeaders)) {
return self::fallbackRoute();
}
if (! blank($input)) {
$input = strtolower($input);
foreach (self::$rules as $rule) {
$conditions = $rule->conditions()->orderByDesc('priority')->get();
$matchCondition = MatchCondition::from($rule->match_condition);
$matches = $matchCondition === MatchCondition::ALL
? $conditions->every(fn ($condition) => self::matchCondition($condition, $dicomHeaders))
: $conditions->contains(fn ($condition) => self::matchCondition($condition, $dicomHeaders));
foreach (self::$rules as $pattern) {
if (ContentMatcher::match($input, $pattern->name, MatchMode::from($pattern->match_mode))) {
return [$pattern->institute_id, $pattern->facility_id];
}
if ($matches) {
return [
'institute_id' => $rule->institute_id,
'facility_id' => $rule->facility_id,
'rule_id' => $rule->id,
'radiologists' => self::getRadiologists($rule),
];
}
}
return [self::$catchAll, null];
return self::fallbackRoute();
}
private static function initialize(): void
{
if (is_null(self::$rules)) {
self::$rules = Cache::remember('dicom.rules',
now()->addMinutes(self::CACHE_TTL),
fn () => DicomRoutingRule::active()
->with('conditions')
->orderByDesc('priority')
->get()
);
}
if (is_null(self::$activeRads)) {
self::$activeRads = Cache::remember('dicom.rads',
now()->addMinutes(self::CACHE_TTL),
fn () => User::active()
->role(Role::Radiologist)
->pluck('id')
);
}
if (self::$catchAll < 0) {
self::$catchAll = Institute::where('name', 'CATCH-ALL')
->first('id')
->id;
}
}
private static function matchCondition(DicomRuleCondition $condition, array $dicomHeaders): bool
{
$dicomTag = $condition->dicom_tag;
$dicomValue = $dicomHeaders[$dicomTag] ?? '';
$searchPattern = $condition->search_pattern;
$matchMode = MatchMode::from($condition->match_mode);
if (! $condition->case_sensitive) {
$dicomValue = strtolower($dicomValue);
if ($matchMode != MatchMode::Regex) {
$searchPattern = strtolower($searchPattern);
}
}
switch ($matchMode) {
case MatchMode::Exact:
return $dicomValue === $searchPattern;
case MatchMode::StartsWith:
return str_starts_with($dicomValue, $searchPattern);
case MatchMode::EndsWith:
return str_ends_with($dicomValue, $searchPattern);
case MatchMode::Contains:
return str_contains($dicomValue, $searchPattern);
case MatchMode::Regex:
return preg_match($searchPattern, $dicomValue) === 1;
}
}
private static function getRadiologists(DicomRoutingRule $rule): array
{
if ($rule->assignment_panel_id) {
$panel = AssignmentPanel::active()
->with('radiologists:id')
->find($rule->assignment_panel_id);
if ($panel) {
$rads = $panel->radiologists->pluck('id')->toArray();
if (! empty($rads)) {
// return only existing id from self::$activeRads
return array_intersect($rads, self::$activeRads->toArray());
}
}
} elseif ($rule->radiologist_id) {
if (self::$activeRads->contains($rule->radiologist_id)) {
return [$rule->radiologist_id];
}
}
return [];
}
}

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('assignment_panels', function (Blueprint $table) {
$table->id();
$table->boolean('is_active')->index();
$table->string('name')->unique();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('assignment_panels');
}
};

View File

@ -0,0 +1,26 @@
<?php
use App\Models\AssignmentPanel;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('assignment_panel_radiologists', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(AssignmentPanel::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(User::class)->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['assignment_panel_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('assignment_panel_radiologists');
}
};

View File

@ -1,8 +1,10 @@
<?php
use App\Domain\Rule\MatchCondition;
use App\Models\AssignmentPanel;
use App\Models\Facility;
use App\Models\Institute;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@ -16,8 +18,10 @@ public function up(): void
$table->boolean('is_active')->default(true);
$table->foreignIdFor(Institute::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(Facility::class)->nullable()->constrained()->cascadeOnDelete();
$table->foreignIdFor(User::class, 'radiologist_id')->nullable()->constrained()->nullOnDelete();
$table->foreignIdFor(AssignmentPanel::class)->nullable()->constrained()->nullOnDelete();
$table->string('description')->nullable();
$table->string('match_condition')->default(MatchCondition::Or->value);
$table->string('match_condition')->default(MatchCondition::ANY->value);
$table->unsignedTinyInteger('priority')->default(0);
$table->timestamps();

View File

@ -14,7 +14,7 @@ class InstituteSeeder extends Seeder
public function run(): void
{
Institute::create([
'name' => 'Catch-all',
'name' => 'CATCH-ALL',
'is_active' => true,
]);
$chev = Institute::create([

98
package-lock.json generated
View File

@ -97,27 +97,27 @@
"@prettier/plugin-php": "0.22.1",
"@rollup/plugin-html": "1.0.3",
"@types/typeahead": "0.11.32",
"ajv": "8.16.0",
"ajv": "^8.17.1",
"autoprefixer": "10.4.19",
"axios": "^1.7.9",
"babel-loader": "9.1.3",
"browser-sync": "2.29.3",
"cross-env": "7.0.3",
"eslint": "8.57.0",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "5.1.3",
"glob": "10.4.5",
"glob": "^10.4.5",
"laravel-datatables-vite": "^0.6.1",
"laravel-vite-plugin": "^1.1.1",
"lodash": "4.17.21",
"postcss": "8.4.39",
"prettier": "3.2.2",
"resolve-url-loader": "5.0.0",
"lodash": "^4.17.21",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"resolve-url-loader": "^5.0.0",
"sass": "1.76.0",
"sass-loader": "14.0.0",
"vite": "^6.0.7",
"sass-loader": "^14.0.0",
"vite": "^6.0.10",
"vite-plugin-static-copy": "^2.2.0"
}
},
@ -4906,16 +4906,16 @@
}
},
"node_modules/ajv": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.16.0.tgz",
"integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==",
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.4.1"
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
@ -7513,6 +7513,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
@ -10375,9 +10392,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.39",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
"integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
"dev": true,
"funding": [
{
@ -10395,9 +10412,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.1",
"source-map-js": "^1.2.0"
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@ -10437,9 +10454,9 @@
}
},
"node_modules/prettier": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.2.tgz",
"integrity": "sha512-HTByuKZzw7utPiDO523Tt2pLtEyK7OibUD9suEJQrPUCYQqrHr74GGX6VidMrovbf/I50mPqr8j/II6oBAuc5A==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
"integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
"dev": true,
"license": "MIT",
"bin": {
@ -12927,9 +12944,9 @@
}
},
"node_modules/vite": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz",
"integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==",
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz",
"integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -13066,35 +13083,6 @@
"node": ">= 10.0.0"
}
},
"node_modules/vite/node_modules/postcss": {
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/vt-pbf": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",