From a75833f77a4880c3d7cdf1a827f23fd4d0237ed0 Mon Sep 17 00:00:00 2001 From: Masroor Ehsan Date: Wed, 22 Jan 2025 17:08:17 +0600 Subject: [PATCH] wip --- app/Domain/Rule/MatchCondition.php | 5 +- app/Models/AssignmentPanel.php | 16 ++ app/Models/AssignmentPanelRadiologist.php | 5 + app/Models/DicomRoutingRule.php | 11 ++ app/Models/User.php | 6 + app/Services/Pacs/Sync/StudiesSync.php | 2 +- app/Services/StudyRouter/DicomStudyRouter.php | 138 +++++++++++++++--- ..._902875_create_assignment_panels_table.php | 23 +++ ...te_assignment_panel_radiologists_table.php | 26 ++++ ...75040_create_dicom_routing_rules_table.php | 6 +- database/seeders/InstituteSeeder.php | 2 +- package-lock.json | 98 ++++++------- 12 files changed, 255 insertions(+), 83 deletions(-) create mode 100644 app/Models/AssignmentPanel.php create mode 100644 app/Models/AssignmentPanelRadiologist.php create mode 100644 database/migrations/2024_12_20_913432_2025_01_22_902875_create_assignment_panels_table.php create mode 100644 database/migrations/2024_12_21_129146_2025_01_22_054215_create_assignment_panel_radiologists_table.php diff --git a/app/Domain/Rule/MatchCondition.php b/app/Domain/Rule/MatchCondition.php index 7768231..c538941 100644 --- a/app/Domain/Rule/MatchCondition.php +++ b/app/Domain/Rule/MatchCondition.php @@ -4,6 +4,7 @@ enum MatchCondition: string { - case And = 'AND'; - case Or = 'OR'; + case ALL = 'ALL'; + case ANY = 'ANY'; + case NONE = 'NONE'; } diff --git a/app/Models/AssignmentPanel.php b/app/Models/AssignmentPanel.php new file mode 100644 index 0000000..9d4ab32 --- /dev/null +++ b/app/Models/AssignmentPanel.php @@ -0,0 +1,16 @@ +hasManyThrough(User::class, AssignmentPanelRadiologist::class); + } +} diff --git a/app/Models/AssignmentPanelRadiologist.php b/app/Models/AssignmentPanelRadiologist.php new file mode 100644 index 0000000..37a0b4e --- /dev/null +++ b/app/Models/AssignmentPanelRadiologist.php @@ -0,0 +1,5 @@ +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 [ diff --git a/app/Models/User.php b/app/Models/User.php index 18c1f22..982418a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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. * diff --git a/app/Services/Pacs/Sync/StudiesSync.php b/app/Services/Pacs/Sync/StudiesSync.php index 75a6a16..d55efbd 100644 --- a/app/Services/Pacs/Sync/StudiesSync.php +++ b/app/Services/Pacs/Sync/StudiesSync.php @@ -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'); diff --git a/app/Services/StudyRouter/DicomStudyRouter.php b/app/Services/StudyRouter/DicomStudyRouter.php index 2a9f0c4..735d1d0 100644 --- a/app/Services/StudyRouter/DicomStudyRouter.php +++ b/app/Services/StudyRouter/DicomStudyRouter.php @@ -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 - */ - 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 []; } } diff --git a/database/migrations/2024_12_20_913432_2025_01_22_902875_create_assignment_panels_table.php b/database/migrations/2024_12_20_913432_2025_01_22_902875_create_assignment_panels_table.php new file mode 100644 index 0000000..439f80a --- /dev/null +++ b/database/migrations/2024_12_20_913432_2025_01_22_902875_create_assignment_panels_table.php @@ -0,0 +1,23 @@ +id(); + $table->boolean('is_active')->index(); + $table->string('name')->unique(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('assignment_panels'); + } +}; diff --git a/database/migrations/2024_12_21_129146_2025_01_22_054215_create_assignment_panel_radiologists_table.php b/database/migrations/2024_12_21_129146_2025_01_22_054215_create_assignment_panel_radiologists_table.php new file mode 100644 index 0000000..e656e45 --- /dev/null +++ b/database/migrations/2024_12_21_129146_2025_01_22_054215_create_assignment_panel_radiologists_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/database/migrations/2024_12_28_175040_create_dicom_routing_rules_table.php b/database/migrations/2024_12_28_175040_create_dicom_routing_rules_table.php index c01f156..4ddb7a6 100644 --- a/database/migrations/2024_12_28_175040_create_dicom_routing_rules_table.php +++ b/database/migrations/2024_12_28_175040_create_dicom_routing_rules_table.php @@ -1,8 +1,10 @@ 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(); diff --git a/database/seeders/InstituteSeeder.php b/database/seeders/InstituteSeeder.php index fcb8f4f..eaa9c4e 100644 --- a/database/seeders/InstituteSeeder.php +++ b/database/seeders/InstituteSeeder.php @@ -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([ diff --git a/package-lock.json b/package-lock.json index 5ccc644..69b7505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",