wip
This commit is contained in:
parent
3f308597c7
commit
a75833f77a
@ -4,6 +4,7 @@
|
||||
|
||||
enum MatchCondition: string
|
||||
{
|
||||
case And = 'AND';
|
||||
case Or = 'OR';
|
||||
case ALL = 'ALL';
|
||||
case ANY = 'ANY';
|
||||
case NONE = 'NONE';
|
||||
}
|
||||
|
16
app/Models/AssignmentPanel.php
Normal file
16
app/Models/AssignmentPanel.php
Normal 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);
|
||||
}
|
||||
}
|
5
app/Models/AssignmentPanelRadiologist.php
Normal file
5
app/Models/AssignmentPanelRadiologist.php
Normal file
@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class AssignmentPanelRadiologist extends BaseModel {}
|
@ -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 [
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
if ($matches) {
|
||||
return [
|
||||
'institute_id' => $rule->institute_id,
|
||||
'facility_id' => $rule->facility_id,
|
||||
'rule_id' => $rule->id,
|
||||
'radiologists' => self::getRadiologists($rule),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return self::fallbackRoute();
|
||||
}
|
||||
|
||||
private static function initialize(): void
|
||||
{
|
||||
if (is_null(self::$rules)) {
|
||||
self::$rules = Cache::remember('dicom.routers',
|
||||
now()->addMinutes(15),
|
||||
fn () => DicomRoutingRule::active()->with('conditions')->get()
|
||||
self::$rules = Cache::remember('dicom.rules',
|
||||
now()->addMinutes(self::CACHE_TTL),
|
||||
fn () => DicomRoutingRule::active()
|
||||
->with('conditions')
|
||||
->orderByDesc('priority')
|
||||
->get()
|
||||
);
|
||||
self::$catchAll = DB::table('institutes')
|
||||
->where('name', 'Catch-all')
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (! blank($input)) {
|
||||
$input = strtolower($input);
|
||||
|
||||
foreach (self::$rules as $pattern) {
|
||||
if (ContentMatcher::match($input, $pattern->name, MatchMode::from($pattern->match_mode))) {
|
||||
return [$pattern->institute_id, $pattern->facility_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);
|
||||
}
|
||||
}
|
||||
|
||||
return [self::$catchAll, null];
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
};
|
@ -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');
|
||||
}
|
||||
};
|
@ -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();
|
||||
|
||||
|
@ -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
98
package-lock.json
generated
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user