diff --git a/app/DataTables/WorklistDataTable.php b/app/DataTables/WorklistDataTable.php index 9086b4a..0491b89 100644 --- a/app/DataTables/WorklistDataTable.php +++ b/app/DataTables/WorklistDataTable.php @@ -238,11 +238,21 @@ public function getColumns(): array break; case WorklistColumn::Organization: + $columns[] = Column::make($col->value) + ->searchable(false) + ->title('Org'); + break; + case WorklistColumn::Department: $columns[] = Column::make($col->value) - ->searchable(true) - ->orderable(true) - ->title(formatTitle($col->value)); + ->searchable(false) + ->title('Dept'); + break; + + case WorklistColumn::DicomServer: + $columns[] = Column::make($col->value) + ->searchable(false) + ->title('Server'); break; default: @@ -445,7 +455,9 @@ private function renderCustomColumns(): array }; break; case WorklistColumn::StudyDescription: - $columns[$col->value] = static fn (Study $study) => $study->sanitizedStudyDescription(); + $columns[$col->value] = function (Study $study) { + return sprintf('%s', $study->study_description, str_limit($study->sanitizedStudyDescription(), 20)); + }; break; case WorklistColumn::AssignedPhysician: $columns[$col->value] = static function (Study $study) { @@ -500,6 +512,13 @@ private function renderCustomColumns(): array case WorklistColumn::ReportButtons: $columns[$col->value] = fn (Study $study) => $this->generateReportingButtons($study); break; + case WorklistColumn::DicomServer: + $columns[$col->value] = function (Study $study) { + return sprintf('%s', + strtolower($study->dicomServer->geo_code), + $study->dicomServer->server_name); + }; + break; } } diff --git a/app/Models/DicomRoutingRule.php b/app/Models/DicomRoutingRule.php index 1fa845d..eace696 100644 --- a/app/Models/DicomRoutingRule.php +++ b/app/Models/DicomRoutingRule.php @@ -2,21 +2,14 @@ namespace App\Models; -use App\Domain\Rule\MatchCondition; 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 { use Active; - public function conditions(): HasMany - { - return $this->hasMany(DicomRuleCondition::class); - } - public function organization(): BelongsTo { return $this->belongsTo(Organization::class); @@ -41,7 +34,6 @@ protected function casts(): array { return [ 'is_active' => 'boolean', - 'match_condition' => MatchCondition::class, ]; } } diff --git a/app/Models/DicomRuleCondition.php b/app/Models/DicomRuleCondition.php deleted file mode 100644 index b4b9668..0000000 --- a/app/Models/DicomRuleCondition.php +++ /dev/null @@ -1,22 +0,0 @@ -belongsTo(DicomRoutingRule::class); - } - - protected function casts(): array - { - return [ - 'case_sensitive' => 'boolean', - 'match_mode' => MatchMode::class, - ]; - } -} diff --git a/app/Services/ACL/WorklistColumn.php b/app/Services/ACL/WorklistColumn.php index 9786d5e..8ac5e34 100644 --- a/app/Services/ACL/WorklistColumn.php +++ b/app/Services/ACL/WorklistColumn.php @@ -36,4 +36,5 @@ enum WorklistColumn: string case ReportButtons = 'report_buttons'; case Organization = 'organization'; case Department = 'department'; + case DicomServer = 'dicom_server'; } diff --git a/app/Services/ACL/WorklistGuard.php b/app/Services/ACL/WorklistGuard.php index 16b923f..dfa3abf 100644 --- a/app/Services/ACL/WorklistGuard.php +++ b/app/Services/ACL/WorklistGuard.php @@ -45,6 +45,7 @@ public static function worklistColumns(User|int|null $usr = null): Collection if ($user->isAdmin()) { $columns->push(WorklistColumn::Organization); $columns->push(WorklistColumn::Department); + $columns->push(WorklistColumn::DicomServer); } return $columns; diff --git a/app/Services/Pacs/Sync/StudiesSync.php b/app/Services/Pacs/Sync/StudiesSync.php index 8325648..eef3776 100644 --- a/app/Services/Pacs/Sync/StudiesSync.php +++ b/app/Services/Pacs/Sync/StudiesSync.php @@ -8,6 +8,7 @@ use App\Services\Pacs\DicomUtils; use App\Services\Pacs\OrthancRestClient; use App\Services\StudyRouter\DicomStudyRouter; +use App\Services\StudyRouter\RawDicomTags; use Carbon\Carbon; use Exception; use Illuminate\Pipeline\Pipeline; @@ -283,20 +284,21 @@ private function getStudyDicomTags(string $study_uuid): array // randomly sample few instances for tags collection $selectedInstances = count($instances) <= $this->maxInstances ? $instances : array_rand(array_flip($instances), $this->maxInstances); - $tags = []; + $tags = collect(); foreach ($selectedInstances as $instance) { foreach ($this->fetchInstancesTags($instance) as $key => $value) { if ($key == 'MainDicomTags' || $key == 'RequestedTags') { foreach ($value as $tag => $val) { - if (! isset($tags[$tag]) || $tags[$tag] !== $val) { - $tags[$tag] = $val; + $dcmTag = RawDicomTags::tryFrom($tag); + if ($dcmTag != null && (! $tags->has($dcmTag->name) || $tags->get($dcmTag->name) !== $val)) { + $tags->put($dcmTag->name, $val); } } } } } - return $tags; + return $tags->toArray(); } private function setValue(array &$array, string $key, mixed $value): void diff --git a/app/Services/StudyRouter/DicomStudyRouter.php b/app/Services/StudyRouter/DicomStudyRouter.php index c72061e..d0c2d1e 100644 --- a/app/Services/StudyRouter/DicomStudyRouter.php +++ b/app/Services/StudyRouter/DicomStudyRouter.php @@ -3,16 +3,15 @@ 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\Models\DicomRuleCondition; use App\Models\Organization; use App\Models\User; -use App\Services\ContentMatcher; +use Exception; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; final class DicomStudyRouter { @@ -30,22 +29,27 @@ public static function matchStudy(array $dicomHeaders): array return self::fallbackRouting(); } + $study = json_decode(json_encode($dicomHeaders)); // convert to object + $expression = new ExpressionLanguage; foreach (self::$rules as $rule) { - $conditions = $rule->conditions()->orderByDesc('priority')->get(); - $matches = $rule->match_condition === MatchCondition::ALL - ? $conditions->every(fn ($condition) => self::matchCondition($condition, $dicomHeaders)) - : $conditions->contains(fn ($condition) => self::matchCondition($condition, $dicomHeaders)); + try { + $matches = (bool) $expression->evaluate($rule->condition, ['study' => $study]); + } catch (Exception $exc) { + Log::error('Error evaluating rule expression', [ + 'rule_id' => $rule->id, + 'condition' => $rule->condition, + 'error' => $exc->getMessage(), + ]); - /* - if ($dicomHeaders[RawDicomTags::Modality->value] === 'CR') { - dd($rule, $conditions->toArray(), $matches, $dicomHeaders[RawDicomTags::Modality->value]); + return self::fallbackRouting(); } - */ + if ($matches) { return [ 'organization_id' => $rule->organization_id, 'department_id' => $rule->department_id, 'rule_id' => $rule->id, + 'rule_name' => $rule->name, 'radiologists' => self::getRadiologists($rule), ]; } @@ -70,7 +74,6 @@ private static function initialize(): void self::$rules = Cache::remember('dicom.rules', now()->addMinutes(self::CACHE_TTL), fn () => DicomRoutingRule::active() - ->with('conditions') ->orderByDesc('priority') ->get() ); @@ -92,22 +95,6 @@ private static function initialize(): void } } - private static function matchCondition(DicomRuleCondition $condition, array $dicomHeaders): bool - { - $dicomTag = $condition->dicom_tag; - $dicomValue = $dicomHeaders[$dicomTag] ?? ''; - $searchPattern = $condition->search_pattern; - - if (! $condition->case_sensitive) { - $dicomValue = strtolower($dicomValue); - if ($condition->match_mode !== MatchMode::Regex) { - $searchPattern = strtolower($searchPattern); - } - } - - return ContentMatcher::match($dicomValue, $searchPattern, $condition->match_mode); - } - private static function getRadiologists(DicomRoutingRule $rule): array { if (! is_null($rule->assignment_panel_id)) { diff --git a/app/Services/StudyRouter/RawDicomTags.php b/app/Services/StudyRouter/RawDicomTags.php index d248157..f1786c2 100644 --- a/app/Services/StudyRouter/RawDicomTags.php +++ b/app/Services/StudyRouter/RawDicomTags.php @@ -4,62 +4,62 @@ enum RawDicomTags: string { - case PatientName = '0010,0010'; // Patient's Name - case PatientID = '0010,0020'; // Patient ID - case PatientBirthDate = '0010,0030'; // Patient's Birth Date - case PatientSex = '0010,0040'; // Patient's Sex - case StudyInstanceUID = '0020,000D'; // Study Instance UID - case SeriesInstanceUID = '0020,000E'; // Series Instance UID - case StudyID = '0020,0010'; // Study ID - case SeriesNumber = '0020,0011'; // Series Number - case InstanceNumber = '0020,0013'; // Instance Number - case SOPClassUID = '0008,0016'; // SOP Class UID - case SOPInstanceUID = '0008,0018'; // SOP Instance UID - case StudyDate = '0008,0020'; // Study Date - case StudyTime = '0008,0030'; // Study Time - case AccessionNumber = '0008,0050'; // Accession Number - case Modality = '0008,0060'; // Modality - case Manufacturer = '0008,0070'; // Manufacturer - case InstitutionName = '0008,0080'; // Institution Name - case ReferringPhysicianName = '0008,0090'; // Referring Physician's Name - case StationName = '0008,1010'; // Station Name - case SeriesDescription = '0008,103E'; // Series Description - case ManufacturerModelName = '0008,1090'; // Manufacturer's Model Name - case PatientAge = '0010,1010'; // Patient's Age - case PatientWeight = '0010,1030'; // Patient's Weight - case BodyPartExamined = '0018,0015'; // Body Part Examined - case ProtocolName = '0018,1030'; // Protocol Name - case SoftwareVersions = '0018,1020'; // Software Versions - case AcquisitionDate = '0008,0022'; // Acquisition Date - case AcquisitionTime = '0008,0032'; // Acquisition Time - case ContentDate = '0008,0023'; // Content Date - case ContentTime = '0008,0033'; // Content Time - case AcquisitionDeviceProcessingDescription = '0018,1400'; // Acquisition Device Processing Description - case InstitutionAddress = '0008,0081'; // Institution Address - case StudyDescription = '0008,1030'; // Study Description - case OperatorsName = '0008,1070'; // Operator's Name - case Private10 = '0029,0010'; // Private Tag 10 - case IW_Private = '0009,0010'; // IW Private Tag - case ImageType = '0008,0008'; // Image Type - case PatientOrientation = '0020,0020'; // Patient Orientation - case ImagePositionPatient = '0020,0032'; // Image Position (Patient) - case ImageOrientationPatient = '0020,0037'; // Image Orientation (Patient) - case FrameOfReferenceUID = '0020,0052'; // Frame of Reference UID - case PositionReferenceIndicator = '0020,1040'; // Position Reference Indicator - case SliceLocation = '0020,1041'; // Slice Location - case SamplesPerPixel = '0028,0002'; // Samples per Pixel - case PhotometricInterpretation = '0028,0004'; // Photometric Interpretation - case Rows = '0028,0010'; // Rows - case Columns = '0028,0011'; // Columns - case PixelSpacing = '0028,0030'; // Pixel Spacing - case BitsAllocated = '0028,0100'; // Bits Allocated - case BitsStored = '0028,0101'; // Bits Stored - case HighBit = '0028,0102'; // High Bit - case PixelRepresentation = '0028,0103'; // Pixel Representation - case WindowCenter = '0028,1050'; // Window Center - case WindowWidth = '0028,1051'; // Window Width - case RescaleIntercept = '0028,1052'; // Rescale Intercept - case RescaleSlope = '0028,1053'; // Rescale Slope - case InoWave_Private = '0011,1060'; - case RadFusion_SenderId = '1971,1020'; + case patient_name = '0010,0010'; // Patient's Name + case patient_id = '0010,0020'; // Patient ID + case patient_birth_date = '0010,0030'; // Patient's Birth Date + case patient_sex = '0010,0040'; // Patient's Sex + case study_instance_uid = '0020,000D'; // Study Instance UID + case series_instance_uid = '0020,000E'; // Series Instance UID + case study_id = '0020,0010'; // Study ID + case series_number = '0020,0011'; // Series Number + case instance_number = '0020,0013'; // Instance Number + case sop_class_uid = '0008,0016'; // SOP Class UID + case sop_instance_uid = '0008,0018'; // SOP Instance UID + case study_date = '0008,0020'; // Study Date + case study_time = '0008,0030'; // Study Time + case accession_number = '0008,0050'; // Accession Number + case modality = '0008,0060'; // Modality + case manufacturer = '0008,0070'; // Manufacturer + case institution_name = '0008,0080'; // Institution Name + case referring_physician_name = '0008,0090'; // Referring Physician's Name + case station_name = '0008,1010'; // Station Name + case series_description = '0008,103E'; // Series Description + case manufacturer_model_name = '0008,1090'; // Manufacturer's Model Name + case patient_age = '0010,1010'; // Patient's Age + case patient_weight = '0010,1030'; // Patient's Weight + case body_part_examined = '0018,0015'; // Body Part Examined + case protocol_name = '0018,1030'; // Protocol Name + case software_versions = '0018,1020'; // Software Versions + case acquisition_date = '0008,0022'; // Acquisition Date + case acquisition_time = '0008,0032'; // Acquisition Time + case content_date = '0008,0023'; // Content Date + case content_time = '0008,0033'; // Content Time + case acquisition_device_processing_description = '0018,1400'; // Acquisition Device Processing Description + case institution_address = '0008,0081'; // Institution Address + case study_description = '0008,1030'; // Study Description + case operators_name = '0008,1070'; // Operator's Name + case private_10 = '0029,0010'; // Private Tag 10 + case iw_private = '0009,0010'; // IW Private Tag + case image_type = '0008,0008'; // Image Type + case patient_orientation = '0020,0020'; // Patient Orientation + case image_position_patient = '0020,0032'; // Image Position (Patient) + case image_orientation_patient = '0020,0037'; // Image Orientation (Patient) + case frame_of_reference_uid = '0020,0052'; // Frame of Reference UID + case position_reference_indicator = '0020,1040'; // Position Reference Indicator + case slice_location = '0020,1041'; // Slice Location + case samples_per_pixel = '0028,0002'; // Samples per Pixel + case photometric_interpretation = '0028,0004'; // Photometric Interpretation + case rows = '0028,0010'; // Rows + case columns = '0028,0011'; // Columns + case pixel_spacing = '0028,0030'; // Pixel Spacing + case bits_allocated = '0028,0100'; // Bits Allocated + case bits_stored = '0028,0101'; // Bits Stored + case high_bit = '0028,0102'; // High Bit + case pixel_representation = '0028,0103'; // Pixel Representation + case window_center = '0028,1050'; // Window Center + case window_width = '0028,1051'; // Window Width + case rescale_intercept = '0028,1052'; // Rescale Intercept + case rescale_slope = '0028,1053'; // Rescale Slope + case ino_wave_private = '0011,1060'; + case rad_fusion_sender_id = '1971,1020'; } diff --git a/composer.json b/composer.json index b4ef141..ac57e12 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "sentry/sentry-laravel": "^4.10", "spatie/laravel-medialibrary": "^11.11", "spatie/laravel-permission": "^6.10", + "symfony/expression-language": "^7.2", "vinkla/hashids": "^12.0", "yajra/laravel-datatables": "^11.0" }, diff --git a/database/migrations/0000_01_31_074312_create_dicom_servers_table.php b/database/migrations/0000_01_31_074312_create_dicom_servers_table.php index 576bfaa..fe8ab3e 100644 --- a/database/migrations/0000_01_31_074312_create_dicom_servers_table.php +++ b/database/migrations/0000_01_31_074312_create_dicom_servers_table.php @@ -14,6 +14,7 @@ public function up(): void $table->id(); $table->boolean('is_active')->index(); $table->string('server_name')->unique(); + $table->string('geo_code', 2)->index(); $table->string('host'); $table->integer('port'); $table->string('rest_api_endpoint'); 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 3574334..8f75ff7 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,6 +1,5 @@ foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete(); $table->foreignIdFor(AssignmentPanel::class)->nullable()->constrained()->nullOnDelete(); $table->string('name')->nullable(); - $table->string('match_condition')->default(MatchCondition::ANY->value); + $table->string('condition'); $table->unsignedTinyInteger('priority')->default(0); $table->timestamps(); diff --git a/database/migrations/2024_12_29_073754_create_dicom_rule_conditions_table.php b/database/migrations/2024_12_29_073754_create_dicom_rule_conditions_table.php deleted file mode 100644 index 07acf00..0000000 --- a/database/migrations/2024_12_29_073754_create_dicom_rule_conditions_table.php +++ /dev/null @@ -1,31 +0,0 @@ -id(); - $table->foreignIdFor(DicomRoutingRule::class)->constrained()->cascadeOnDelete(); - $table->string('dicom_tag'); - $table->string('search_pattern'); - $table->boolean('case_sensitive')->default(false); - $table->string('match_mode')->default(MatchMode::Exact->value); - $table->unsignedTinyInteger('priority')->default(0); - $table->timestamps(); - - $table->index(['dicom_routing_rule_id', 'priority']); - }); - } - - public function down(): void - { - Schema::dropIfExists('dicom_rule_conditions'); - } -}; diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php index 16f8ba3..dad73fb 100644 --- a/database/seeders/OrganizationSeeder.php +++ b/database/seeders/OrganizationSeeder.php @@ -2,14 +2,10 @@ namespace Database\Seeders; -use App\Domain\Rule\MatchCondition; -use App\Domain\Rule\MatchMode; use App\Models\Department; use App\Models\DicomRoutingRule; -use App\Models\DicomRuleCondition; use App\Models\DicomServer; use App\Models\Organization; -use App\Services\StudyRouter\RawDicomTags; use Illuminate\Database\Seeder; class OrganizationSeeder extends Seeder @@ -36,14 +32,14 @@ public function run(): void $dept_chev_xr = Department::create( [ 'is_active' => true, - 'name' => 'Chevron XR', + 'name' => 'Chev-CR', 'organization_id' => $chev->id, ] ); $chev_dep_ct_mr = Department::create( [ 'is_active' => true, - 'name' => 'Chevron CT/MR', + 'name' => 'Chev-MR', 'organization_id' => $chev->id, ] ); @@ -61,7 +57,7 @@ public function run(): void 'name' => 'Chevron MR/CT', 'organization_id' => $chev->id, 'department_id' => $chev_dep_ct_mr->id, - 'match_condition' => MatchCondition::ALL->value, + 'condition' => '(study.institution_name ?? "" starts with "chevron") and (study.modality not in ["CR", "DX", "MG"])', ] ); @@ -71,45 +67,16 @@ public function run(): void 'name' => 'Chevron X-ray', 'organization_id' => $chev->id, 'department_id' => $dept_chev_xr->id, - 'match_condition' => MatchCondition::ALL->value, - 'priority' => 10, + 'condition' => '(study.institution_name ?? "" starts with "chevron") and (study.modality in ["CR", "DX", "MG"])', + 'priority' => 99, ] ); - DicomRuleCondition::create([ - 'dicom_tag' => RawDicomTags::InstitutionName->value, - 'search_pattern' => 'chevron', - 'dicom_routing_rule_id' => $rul_chev_mr_ct->id, - 'match_mode' => MatchMode::Contains->value, - 'case_sensitive' => false, - ]); - DicomRuleCondition::create([ - 'dicom_tag' => RawDicomTags::Modality->value, - 'search_pattern' => 'MR,CT', - 'dicom_routing_rule_id' => $rul_chev_mr_ct->id, - 'match_mode' => MatchMode::InList->value, - 'case_sensitive' => true, - ]); - - DicomRuleCondition::create([ - 'dicom_tag' => RawDicomTags::InstitutionName->value, - 'search_pattern' => 'chevron', - 'dicom_routing_rule_id' => $rul_chev_xr->id, - 'match_mode' => MatchMode::Contains->value, - 'case_sensitive' => false, - ]); - DicomRuleCondition::create([ - 'dicom_tag' => RawDicomTags::Modality->value, - 'search_pattern' => 'CR,DX,MG', - 'dicom_routing_rule_id' => $rul_chev_xr->id, - 'match_mode' => MatchMode::InList->value, - 'case_sensitive' => true, - ]); - DicomServer::create( [ 'is_active' => true, - 'server_name' => 'Orthanc Main', + 'server_name' => 'CTG-1', + 'geo_code' => 'BD', 'host' => 'pacs.mylabctg.com', 'port' => 8042, 'rest_api_endpoint' => 'http://pacs.mylabctg.com:8042/', @@ -122,8 +89,9 @@ public function run(): void DicomServer::create( [ 'is_active' => false, - 'server_name' => 'Orthanc XR', + 'server_name' => 'MAA-1', 'host' => 'pacs.mylabctg.com', + 'geo_code' => 'IN', 'port' => 8043, 'rest_api_endpoint' => 'http://pacs.mylabctg.com:8042/', 'ae_title' => 'RADFUSION', diff --git a/resources/views/staff/worklist/index.blade.php b/resources/views/staff/worklist/index.blade.php index 3d1f75a..da3e290 100644 --- a/resources/views/staff/worklist/index.blade.php +++ b/resources/views/staff/worklist/index.blade.php @@ -27,6 +27,8 @@ 'resources/assets/vendor/libs/bootstrap-datepicker/bootstrap-datepicker.scss', 'resources/assets/vendor/libs/bootstrap-daterangepicker/bootstrap-daterangepicker.scss', ]) + + @endsection @section('vendor-script')