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')