PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
routing_manager.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
20
21#include <algorithm>
22#include <cctype>
23
24namespace kcenon::pacs::client {
25
26// =============================================================================
27// Additional DICOM Tags (not in constants)
28// =============================================================================
29
30namespace {
31
32// Institutional Department Name (0008,1040)
33inline constexpr core::dicom_tag institutional_department_name{0x0008, 0x1040};
34
35// Body Part Examined (0018,0015)
36inline constexpr core::dicom_tag body_part_examined{0x0018, 0x0015};
37
38// =============================================================================
39// Helper Functions
40// =============================================================================
41
43[[nodiscard]] std::string to_lower(std::string_view str) {
44 std::string result;
45 result.reserve(str.size());
46 for (char c : str) {
47 result += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
48 }
49 return result;
50}
51
52} // namespace
53
54// =============================================================================
55// Construction / Destruction
56// =============================================================================
57
59 std::shared_ptr<storage::routing_repository> repo,
60 std::shared_ptr<job_manager> job_mgr,
61 std::shared_ptr<di::ILogger> logger)
62 : config_{}
63 , repo_(std::move(repo))
64 , job_manager_(std::move(job_mgr))
65 , logger_(logger ? std::move(logger) : di::null_logger()) {
66
67 load_rules();
68
69 if (logger_) {
70 logger_->info_fmt("routing_manager: Initialized with {} rules", rules_.size());
71 }
72}
73
75 const routing_manager_config& config,
76 std::shared_ptr<storage::routing_repository> repo,
77 std::shared_ptr<job_manager> job_mgr,
78 std::shared_ptr<di::ILogger> logger)
79 : config_(config)
80 , repo_(std::move(repo))
81 , job_manager_(std::move(job_mgr))
82 , logger_(logger ? std::move(logger) : di::null_logger()) {
83
85 load_rules();
86
87 if (logger_) {
88 logger_->info_fmt("routing_manager: Initialized with {} rules (enabled={})",
89 rules_.size(), enabled_.load());
90 }
91}
92
96
97// =============================================================================
98// Rule CRUD
99// =============================================================================
100
101kcenon::pacs::VoidResult routing_manager::add_rule(const routing_rule& rule) {
102 // Validate
103 if (rule.rule_id.empty()) {
104 return kcenon::pacs::pacs_void_error(-1, "Rule ID cannot be empty");
105 }
106
107 {
108 std::shared_lock lock(rules_mutex_);
109 if (rules_.size() >= config_.max_rules) {
110 return kcenon::pacs::pacs_void_error(-1, "Maximum number of rules reached");
111 }
112 }
113
114 // Save to repository
115 auto result = repo_->save(rule);
116 if (result.is_err()) {
118 result.error().code,
119 "Failed to save rule: " + result.error().message);
120 }
121
122 // Update cache
123 {
124 std::unique_lock lock(rules_mutex_);
125 rules_.push_back(rule);
126
127 // Sort by priority (descending)
128 std::sort(rules_.begin(), rules_.end(),
129 [](const routing_rule& a, const routing_rule& b) {
130 return a.priority > b.priority;
131 });
132 }
133
134 if (logger_) {
135 logger_->info_fmt("routing_manager: Added rule: {} ({})", rule.rule_id, rule.name);
136 }
137
138 return kcenon::pacs::ok();
139}
140
141kcenon::pacs::VoidResult routing_manager::update_rule(const routing_rule& rule) {
142 // Save to repository
143 auto result = repo_->save(rule);
144 if (result.is_err()) {
146 result.error().code,
147 "Failed to update rule: " + result.error().message);
148 }
149
150 // Update cache
151 {
152 std::unique_lock lock(rules_mutex_);
153 auto it = std::find_if(rules_.begin(), rules_.end(),
154 [&](const routing_rule& r) {
155 return r.rule_id == rule.rule_id;
156 });
157 if (it != rules_.end()) {
158 *it = rule;
159 } else {
160 rules_.push_back(rule);
161 }
162
163 // Re-sort by priority
164 std::sort(rules_.begin(), rules_.end(),
165 [](const routing_rule& a, const routing_rule& b) {
166 return a.priority > b.priority;
167 });
168 }
169
170 if (logger_) {
171 logger_->info_fmt("routing_manager: Updated rule: {} ({})", rule.rule_id, rule.name);
172 }
173
174 return kcenon::pacs::ok();
175}
176
177kcenon::pacs::VoidResult routing_manager::remove_rule(std::string_view rule_id) {
178 // Remove from repository
179 auto result = repo_->remove(std::string(rule_id));
180 if (!result.is_ok()) {
181 return result;
182 }
183
184 // Remove from cache
185 {
186 std::unique_lock lock(rules_mutex_);
187 rules_.erase(
188 std::remove_if(rules_.begin(), rules_.end(),
189 [&](const routing_rule& r) {
190 return r.rule_id == rule_id;
191 }),
192 rules_.end());
193 }
194
195 if (logger_) {
196 logger_->info_fmt("routing_manager: Removed rule: {}", rule_id);
197 }
198
199 return kcenon::pacs::ok();
200}
201
202std::optional<routing_rule> routing_manager::get_rule(std::string_view rule_id) const {
203 std::shared_lock lock(rules_mutex_);
204 auto it = std::find_if(rules_.begin(), rules_.end(),
205 [&](const routing_rule& r) {
206 return r.rule_id == rule_id;
207 });
208 if (it != rules_.end()) {
209 return *it;
210 }
211 return std::nullopt;
212}
213
214std::vector<routing_rule> routing_manager::list_rules() const {
215 std::shared_lock lock(rules_mutex_);
216 return rules_;
217}
218
219std::vector<routing_rule> routing_manager::list_enabled_rules() const {
220 std::shared_lock lock(rules_mutex_);
221 std::vector<routing_rule> result;
222 std::copy_if(rules_.begin(), rules_.end(), std::back_inserter(result),
223 [](const routing_rule& r) { return r.enabled; });
224 return result;
225}
226
227// =============================================================================
228// Rule Ordering
229// =============================================================================
230
231kcenon::pacs::VoidResult routing_manager::set_rule_priority(std::string_view rule_id, int priority) {
232 auto result = repo_->update_priority(rule_id, priority);
233 if (!result.is_ok()) {
234 return result;
235 }
236
237 {
238 std::unique_lock lock(rules_mutex_);
239 auto it = std::find_if(rules_.begin(), rules_.end(),
240 [&](const routing_rule& r) {
241 return r.rule_id == rule_id;
242 });
243 if (it != rules_.end()) {
244 it->priority = priority;
245 }
246
247 // Re-sort
248 std::sort(rules_.begin(), rules_.end(),
249 [](const routing_rule& a, const routing_rule& b) {
250 return a.priority > b.priority;
251 });
252 }
253
254 return kcenon::pacs::ok();
255}
256
257kcenon::pacs::VoidResult routing_manager::reorder_rules(const std::vector<std::string>& rule_ids) {
258 std::unique_lock lock(rules_mutex_);
259
260 // Assign priorities based on order (first = highest)
261 int priority = static_cast<int>(rule_ids.size());
262 for (const auto& rule_id : rule_ids) {
263 auto repo_result = repo_->update_priority(rule_id, priority);
264 if (!repo_result.is_ok()) {
265 return repo_result;
266 }
267
268 auto it = std::find_if(rules_.begin(), rules_.end(),
269 [&](const routing_rule& r) {
270 return r.rule_id == rule_id;
271 });
272 if (it != rules_.end()) {
273 it->priority = priority;
274 }
275 --priority;
276 }
277
278 // Re-sort
279 std::sort(rules_.begin(), rules_.end(),
280 [](const routing_rule& a, const routing_rule& b) {
281 return a.priority > b.priority;
282 });
283
284 return kcenon::pacs::ok();
285}
286
287// =============================================================================
288// Rule Evaluation
289// =============================================================================
290
291std::vector<routing_action> routing_manager::evaluate(const core::dicom_dataset& dataset) {
292 std::vector<routing_action> result;
293
294 if (!enabled_.load()) {
295 return result;
296 }
297
299
300 std::shared_lock lock(rules_mutex_);
301
302 for (const auto& rule : rules_) {
303 if (!rule.is_effective_now()) {
304 continue;
305 }
306
307 // Check all conditions (AND logic)
308 bool all_match = !rule.conditions.empty();
309 for (const auto& condition : rule.conditions) {
310 if (!match_condition(condition, dataset)) {
311 all_match = false;
312 break;
313 }
314 }
315
316 if (all_match) {
318
319 // Add all actions from this rule
320 for (const auto& action : rule.actions) {
321 result.push_back(action);
322 }
323
324 // Only first matching rule is executed (stop after first match)
325 // Uncomment the break below if you want first-match-only behavior
326 // break;
327 }
328 }
329
330 return result;
331}
332
333std::vector<std::pair<std::string, std::vector<routing_action>>>
335 std::vector<std::pair<std::string, std::vector<routing_action>>> result;
336
337 if (!enabled_.load()) {
338 return result;
339 }
340
342
343 std::shared_lock lock(rules_mutex_);
344
345 for (const auto& rule : rules_) {
346 if (!rule.is_effective_now()) {
347 continue;
348 }
349
350 bool all_match = !rule.conditions.empty();
351 for (const auto& condition : rule.conditions) {
352 if (!match_condition(condition, dataset)) {
353 all_match = false;
354 break;
355 }
356 }
357
358 if (all_match) {
360 result.emplace_back(rule.rule_id, rule.actions);
361 }
362 }
363
364 return result;
365}
366
367// =============================================================================
368// Routing Execution
369// =============================================================================
370
372 if (!enabled_.load()) {
373 return;
374 }
375
376 auto sop_instance_uid = dataset.get_string(core::tags::sop_instance_uid);
377 if (sop_instance_uid.empty()) {
378 if (logger_) {
379 logger_->warn("routing_manager: Cannot route dataset without SOP Instance UID");
380 }
381 return;
382 }
383
384 auto matches = evaluate_with_rule_ids(dataset);
385
386 for (const auto& [rule_id, actions] : matches) {
387 // Update statistics
388 auto stat_result = repo_->increment_triggered(rule_id);
389 if (!stat_result.is_ok() && logger_) {
390 logger_->warn_fmt("routing_manager: Failed to update statistics for rule: {}",
391 rule_id);
392 }
393
394 // Notify callback
395 if (routing_callback_) {
396 routing_callback_(rule_id, sop_instance_uid, actions);
397 }
398
399 // Execute actions
400 execute_actions(sop_instance_uid, actions);
401 }
402}
403
404void routing_manager::route(std::string_view sop_instance_uid) {
405 if (!enabled_.load()) {
406 return;
407 }
408
409 // Note: This would require loading the dataset from storage
410 // For now, log a warning as this requires additional infrastructure
411 if (logger_) {
412 logger_->warn_fmt("routing_manager: route(sop_instance_uid) not fully implemented - "
413 "use route(dataset) instead. UID: {}", sop_instance_uid);
414 }
415}
416
417// =============================================================================
418// Enable/Disable
419// =============================================================================
420
422 enabled_.store(true);
423 if (logger_) {
424 logger_->info("routing_manager: Routing enabled");
425 }
426}
427
429 enabled_.store(false);
430 if (logger_) {
431 logger_->info("routing_manager: Routing disabled");
432 }
433}
434
435bool routing_manager::is_enabled() const noexcept {
436 return enabled_.load();
437}
438
439// =============================================================================
440// Storage SCP Integration
441// =============================================================================
442
445
446 attached_scp_ = &scp;
447
448 // Register post-store handler
450 [this](const core::dicom_dataset& dataset,
451 const std::string& /*patient_id*/,
452 const std::string& /*study_uid*/,
453 const std::string& /*series_uid*/,
454 const std::string& /*sop_instance_uid*/) {
455 this->route(dataset);
456 });
457
458 if (logger_) {
459 logger_->info("routing_manager: Attached to Storage SCP");
460 }
461}
462
464 if (attached_scp_) {
466 attached_scp_ = nullptr;
467
468 if (logger_) {
469 logger_->info("routing_manager: Detached from Storage SCP");
470 }
471 }
472}
473
474// =============================================================================
475// Event Callbacks
476// =============================================================================
477
481
482// =============================================================================
483// Testing (Dry Run)
484// =============================================================================
485
487 routing_test_result result;
488
489 std::shared_lock lock(rules_mutex_);
490
491 for (const auto& rule : rules_) {
492 if (!rule.is_effective_now()) {
493 continue;
494 }
495
496 bool all_match = !rule.conditions.empty();
497 for (const auto& condition : rule.conditions) {
498 if (!match_condition(condition, dataset)) {
499 all_match = false;
500 break;
501 }
502 }
503
504 if (all_match) {
505 result.matched = true;
506 result.matched_rule_id = rule.rule_id;
507 result.actions = rule.actions;
508 break; // Return first match for test
509 }
510 }
511
512 return result;
513}
514
515// =============================================================================
516// Statistics
517// =============================================================================
518
520 routing_statistics stats;
521 stats.total_evaluated = total_evaluated_.load();
522 stats.total_matched = total_matched_.load();
523 stats.total_forwarded = total_forwarded_.load();
524 stats.total_failed = total_failed_.load();
525 return stats;
526}
527
529 routing_statistics stats;
530
531#ifdef PACS_WITH_DATABASE_SYSTEM
532 auto rule_result = repo_->find_by_id(std::string(rule_id));
533 if (rule_result.is_ok()) {
534 const auto& rule = rule_result.value();
535 stats.total_evaluated = 0; // Not tracked per-rule
536 stats.total_matched = rule.triggered_count;
537 stats.total_forwarded = rule.success_count;
538 stats.total_failed = rule.failure_count;
539 }
540#else
541 auto rule = repo_->find_by_id(rule_id);
542 if (rule) {
543 stats.total_evaluated = 0; // Not tracked per-rule
544 stats.total_matched = rule->triggered_count;
545 stats.total_forwarded = rule->success_count;
546 stats.total_failed = rule->failure_count;
547 }
548#endif
549
550 return stats;
551}
552
554 total_evaluated_.store(0);
555 total_matched_.store(0);
556 total_forwarded_.store(0);
557 total_failed_.store(0);
558
559 // Reset per-rule statistics
560 std::shared_lock lock(rules_mutex_);
561 for (const auto& rule : rules_) {
562 auto result = repo_->reset_statistics(rule.rule_id);
563 if (!result.is_ok() && logger_) {
564 logger_->warn_fmt("routing_manager: Failed to reset statistics for rule: {}",
565 rule.rule_id);
566 }
567 }
568}
569
570// =============================================================================
571// Configuration
572// =============================================================================
573
575 return config_;
576}
577
578// =============================================================================
579// Private Implementation
580// =============================================================================
581
583 const core::dicom_dataset& dataset) const {
584 auto value = get_field_value(condition.match_field, dataset);
585 bool matched = match_pattern(condition.pattern, value, condition.case_sensitive);
586
587 // Apply negation if needed
588 if (condition.negate) {
589 matched = !matched;
590 }
591
592 return matched;
593}
594
595bool routing_manager::match_pattern(std::string_view pattern,
596 std::string_view value,
597 bool case_sensitive) const {
598 // Convert to lowercase if case-insensitive
599 std::string pat_str = case_sensitive ? std::string(pattern) : to_lower(pattern);
600 std::string val_str = case_sensitive ? std::string(value) : to_lower(value);
601
602 // Simple wildcard matching (* and ?)
603 // * matches any sequence of characters
604 // ? matches any single character
605
606 const char* p = pat_str.c_str();
607 const char* v = val_str.c_str();
608
609 const char* star_p = nullptr;
610 const char* star_v = nullptr;
611
612 while (*v) {
613 if (*p == '*') {
614 star_p = p++;
615 star_v = v;
616 } else if (*p == '?' || *p == *v) {
617 ++p;
618 ++v;
619 } else if (star_p) {
620 p = star_p + 1;
621 v = ++star_v;
622 } else {
623 return false;
624 }
625 }
626
627 while (*p == '*') {
628 ++p;
629 }
630
631 return *p == '\0';
632}
633
635 const core::dicom_dataset& dataset) const {
636 switch (field) {
638 return dataset.get_string(core::tags::modality);
639
641 return dataset.get_string(core::tags::station_name);
642
645
647 return dataset.get_string(institutional_department_name);
648
651
654
657
659 return dataset.get_string(body_part_examined);
660
662 return dataset.get_string(core::tags::patient_id);
663
666
667 default:
668 return "";
669 }
670}
671
672void routing_manager::execute_actions(const std::string& sop_instance_uid,
673 const std::vector<routing_action>& actions) {
674 for (const auto& action : actions) {
675 if (action.destination_node_id.empty()) {
676 if (logger_) {
677 logger_->warn_fmt("routing_manager: Skipping action with empty destination for UID: {}",
678 sop_instance_uid);
679 }
680 continue;
681 }
682
683 // Create a store job via job_manager
684 std::vector<std::string> instance_uids{sop_instance_uid};
685
686 auto job_id = job_manager_->create_store_job(
687 action.destination_node_id,
688 instance_uids,
689 action.priority);
690
692
693 if (logger_) {
694 logger_->info_fmt("routing_manager: Created forward job {} for UID {} -> {}",
695 job_id, sop_instance_uid, action.destination_node_id);
696 }
697
698 // Note: Delayed forwarding would require a scheduler/timer
699 // For now, jobs are created immediately
700 if (action.delay.count() > 0 && logger_) {
701 logger_->debug_fmt("routing_manager: Delayed forwarding ({} min) not yet implemented",
702 action.delay.count());
703 }
704 }
705}
706
708#ifdef PACS_WITH_DATABASE_SYSTEM
709 auto loaded_result = repo_->find_enabled_rules();
710 if (loaded_result.is_err()) {
711 if (logger_) {
712 logger_->warn_fmt("routing_manager: Failed to load rules: {}",
713 loaded_result.error().message);
714 }
715 return;
716 }
717
718 std::unique_lock lock(rules_mutex_);
719 rules_ = std::move(loaded_result.value());
720#else
721 auto loaded = repo_->find_enabled_rules();
722
723 std::unique_lock lock(rules_mutex_);
724 rules_ = std::move(loaded);
725#endif
726
727 // Sort by priority (descending)
728 std::sort(rules_.begin(), rules_.end(),
729 [](const routing_rule& a, const routing_rule& b) {
730 return a.priority > b.priority;
731 });
732}
733
734} // namespace kcenon::pacs::client
void attach_to_storage_scp(services::storage_scp &scp)
Attach to a Storage SCP for automatic routing.
void set_routing_callback(routing_event_callback callback)
Set callback for routing events.
auto test_rules(const core::dicom_dataset &dataset) const -> routing_test_result
Test rules against a dataset without executing actions.
auto get_field_value(routing_field field, const core::dicom_dataset &dataset) const -> std::string
Get DICOM field value from dataset.
auto get_statistics() const -> routing_statistics
Get overall routing statistics.
auto get_rule(std::string_view rule_id) const -> std::optional< routing_rule >
Get a routing rule by ID.
auto remove_rule(std::string_view rule_id) -> kcenon::pacs::VoidResult
Remove a routing rule.
auto match_pattern(std::string_view pattern, std::string_view value, bool case_sensitive) const -> bool
Match a wildcard pattern against a value.
void enable()
Enable routing globally.
auto get_rule_statistics(std::string_view rule_id) const -> routing_statistics
Get statistics for a specific rule.
routing_manager(std::shared_ptr< storage::routing_repository > repo, std::shared_ptr< job_manager > job_manager, std::shared_ptr< di::ILogger > logger=nullptr)
Construct a routing manager with default configuration.
auto evaluate(const core::dicom_dataset &dataset) -> std::vector< routing_action >
Evaluate rules against a dataset.
auto set_rule_priority(std::string_view rule_id, int priority) -> kcenon::pacs::VoidResult
Set the priority of a rule.
auto config() const noexcept -> const routing_manager_config &
Get current configuration.
void route(const core::dicom_dataset &dataset)
Route a DICOM dataset based on matching rules.
std::shared_ptr< storage::routing_repository > repo_
auto reorder_rules(const std::vector< std::string > &rule_ids) -> kcenon::pacs::VoidResult
Reorder rules by specifying the desired order.
void load_rules()
Load rules from repository into cache.
std::shared_ptr< di::ILogger > logger_
std::shared_ptr< job_manager > job_manager_
void reset_statistics()
Reset all statistics.
auto match_condition(const routing_condition &condition, const core::dicom_dataset &dataset) const -> bool
Check if a condition matches a dataset.
auto is_enabled() const noexcept -> bool
Check if routing is enabled.
void disable()
Disable routing globally.
auto list_rules() const -> std::vector< routing_rule >
List all routing rules.
auto add_rule(const routing_rule &rule) -> kcenon::pacs::VoidResult
Add a new routing rule.
void detach_from_storage_scp()
Detach from the currently attached Storage SCP.
auto evaluate_with_rule_ids(const core::dicom_dataset &dataset) -> std::vector< std::pair< std::string, std::vector< routing_action > > >
Evaluate rules and return with matched rule IDs.
auto update_rule(const routing_rule &rule) -> kcenon::pacs::VoidResult
Update an existing routing rule.
auto list_enabled_rules() const -> std::vector< routing_rule >
List only enabled routing rules.
std::vector< routing_rule > rules_
void execute_actions(const std::string &sop_instance_uid, const std::vector< routing_action > &actions)
Execute routing actions.
auto get_string(dicom_tag tag, std::string_view default_value="") const -> std::string
Get the string value of an element.
void set_post_store_handler(post_store_handler handler)
DICOM Dataset - ordered collection of Data Elements.
DICOM Tag representation (Group, Element pairs)
Compile-time constants for commonly used DICOM tags.
Job manager for asynchronous DICOM operations.
routing_field
DICOM field to match in routing conditions.
@ series_description
(0008,103E) Series Description
@ department
(0008,1040) Institutional Department Name
@ body_part
(0018,0015) Body Part Examined
@ study_description
(0008,1030) Study Description
@ modality
(0008,0060) Modality - CT, MR, US, etc.
@ sop_class_uid
(0008,0016) SOP Class UID
@ institution
(0008,0080) Institution Name
@ station_ae
(0008,1010) Station Name or calling AE
@ patient_id_pattern
(0010,0020) Patient ID (pattern matching)
@ referring_physician
(0008,0090) Referring Physician's Name
std::function< void( const std::string &rule_id, const std::string &instance_uid, const std::vector< routing_action > &triggered_actions)> routing_event_callback
Callback type for routing events.
constexpr dicom_tag referring_physician_name
Referring Physician's Name.
constexpr dicom_tag institution_name
Institution Name.
constexpr dicom_tag study_description
Study Description.
constexpr dicom_tag patient_id
Patient ID.
constexpr dicom_tag sop_instance_uid
SOP Instance UID.
constexpr dicom_tag modality
Modality.
constexpr dicom_tag station_name
Station Name.
constexpr dicom_tag series_description
Series Description.
constexpr dicom_tag sop_class_uid
SOP Class UID.
VoidResult pacs_void_error(int code, const std::string &message, const std::string &details="")
Create a PACS void error result.
Definition result.h:249
Routing manager for automatic DICOM image forwarding.
Repository for routing rule persistence using base_repository pattern.
DICOM Storage SCP service (C-STORE handler)
A single condition for routing rule evaluation.
std::string pattern
Pattern to match (supports wildcards: *, ?)
routing_field match_field
The DICOM field to match.
bool negate
Invert the match result.
bool case_sensitive
Whether matching is case-sensitive.
Configuration for the routing manager.
size_t max_rules
Maximum number of rules.
A complete routing rule with conditions and actions.
std::string name
Human-readable name.
std::string rule_id
Unique rule identifier.
Statistics for routing operations.
Result of testing rules against a dataset (dry run)
std::string matched_rule_id
ID of the matched rule.
bool matched
Whether any rule matched.
std::vector< routing_action > actions
Actions that would execute.