1use crate as eg;
2use eg::common::holds;
3use eg::common::settings::Settings;
4use eg::common::trigger;
5use eg::constants as C;
6use eg::date;
7use eg::{Editor, EgError, EgResult, EgValue};
8use rand;
9use rand::seq::SliceRandom;
10use std::collections::{HashMap, HashSet};
11use std::fmt;
12
13const PRECACHE_ORG_SETTINGS: &[&str] = &[
14 "circ.pickup_hold_stalling.hard",
15 "circ.holds.max_org_unit_target_loops",
16 "circ.holds.org_unit_target_weight",
17 "circ.holds.recall_threshold",
18];
19
20#[derive(Debug)]
22pub struct PotentialCopy {
23 id: i64,
24 status: i64,
25 circ_lib: i64,
26 proximity: i64,
27 already_targeted: bool,
28}
29
30#[derive(Debug)]
36pub struct HoldTargetContext {
37 success: bool,
39
40 hold_id: i64,
42
43 hold: EgValue,
44
45 pickup_lib: i64,
46
47 target: i64,
51
52 find_copy: i64,
54
55 previous_copy_id: i64,
57
58 valid_previous_copy: Option<PotentialCopy>,
60
61 found_copy: bool,
63
64 eligible_copy_count: usize,
66
67 copies: Vec<PotentialCopy>,
68
69 recall_copies: Vec<PotentialCopy>,
72
73 otherwise_targeted_copies: Vec<PotentialCopy>,
76
77 weighted_prox_map: HashMap<i64, Vec<i64>>,
79}
80
81impl HoldTargetContext {
82 fn new(hold_id: i64, hold: EgValue) -> HoldTargetContext {
83 let pickup_lib = hold["pickup_lib"].int().expect("Hold Pickup Lib Required");
85
86 HoldTargetContext {
87 success: false,
88 hold_id,
89 hold,
90 pickup_lib,
91 copies: Vec::new(),
92 recall_copies: Vec::new(),
93 otherwise_targeted_copies: Vec::new(),
94 weighted_prox_map: HashMap::new(),
95 eligible_copy_count: 0,
96 target: 0,
97 find_copy: 0,
98 valid_previous_copy: None,
99 previous_copy_id: 0,
100 found_copy: false,
101 }
102 }
103
104 pub fn hold_id(&self) -> i64 {
105 self.hold_id
106 }
107 pub fn success(&self) -> bool {
108 self.success
109 }
110 pub fn found_copy(&self) -> bool {
111 self.found_copy
112 }
113 pub fn to_json(&self) -> EgValue {
115 eg::hash! {
116 "hold": self.hold_id,
117 "success": self.success,
118 "target": self.target,
119 "old_target": self.previous_copy_id,
120 "found_copy": self.found_copy,
121 "eligible_copies": self.eligible_copy_count,
122 }
123 }
124}
125
126pub struct HoldTargeter<'a> {
128 editor: &'a mut Editor,
129
130 settings: Settings,
131
132 hold_id: i64,
134
135 retarget_time: Option<String>,
136 retarget_interval: Option<String>,
137 soft_retarget_interval: Option<String>,
138 soft_retarget_time: Option<String>,
139 next_check_interval: Option<String>,
140
141 closed_orgs: Vec<i64>,
143
144 hopeless_prone_statuses: Vec<i64>,
146
147 parallel_count: u8,
149
150 parallel_slot: u8,
152
153 newest_first: bool,
155
156 transaction_manged_externally: bool,
167
168 thread_rng: rand::rngs::ThreadRng,
169}
170
171impl fmt::Display for HoldTargeter<'_> {
172 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
173 write!(f, "targeter: [hold={}]", self.hold_id)
174 }
175}
176
177impl<'a> HoldTargeter<'a> {
178 pub fn new(editor: &'a mut Editor) -> HoldTargeter<'a> {
179 let settings = Settings::new(editor);
180
181 HoldTargeter {
182 editor,
183 settings,
184 hold_id: 0,
185 retarget_time: None,
186 retarget_interval: None,
187 soft_retarget_interval: None,
188 soft_retarget_time: None,
189 next_check_interval: None,
190 parallel_count: 0,
191 parallel_slot: 0,
192 newest_first: false,
193 closed_orgs: Vec::new(),
194 hopeless_prone_statuses: Vec::new(),
195 transaction_manged_externally: false,
196 thread_rng: rand::thread_rng(),
197 }
198 }
199
200 pub fn set_transaction_manged_externally(&mut self, val: bool) {
209 self.transaction_manged_externally = val;
210 }
211
212 pub fn editor(&mut self) -> &mut Editor {
213 self.editor
214 }
215
216 pub fn set_parallel_count(&mut self, count: u8) {
217 self.parallel_count = count;
218 }
219
220 pub fn set_parallel_slot(&mut self, slot: u8) {
221 self.parallel_slot = slot;
222 }
223
224 pub fn set_retarget_interval(&mut self, intvl: &str) {
225 self.retarget_interval = Some(intvl.to_string());
226 }
227
228 pub fn set_soft_retarget_interval(&mut self, intvl: &str) {
229 self.soft_retarget_interval = Some(intvl.to_string());
230 }
231
232 pub fn set_next_check_interval(&mut self, intvl: &str) {
233 self.next_check_interval = Some(intvl.to_string());
234 }
235
236 pub fn init(&mut self) -> EgResult<()> {
237 let retarget_intvl_bind;
238 let retarget_intvl = if let Some(intvl) = self.retarget_interval.as_ref() {
239 intvl
240 } else {
241 let query = eg::hash! {
242 "name": "circ.holds.retarget_interval",
243 "enabled": "t"
244 };
245
246 if let Some(intvl) = self.editor().search("cgf", query)?.first() {
247 retarget_intvl_bind = intvl["value"].to_string();
248 retarget_intvl_bind.as_ref().unwrap()
249 } else {
250 "24 h"
252 }
253 };
254
255 log::info!("{self} using retarget interval: {retarget_intvl}");
256
257 let retarget_date = date::subtract_interval(date::now(), retarget_intvl)?;
258 let rt = date::to_iso(&retarget_date);
259
260 log::info!("{self} using retarget time: {rt}");
261
262 self.retarget_time = Some(rt);
263
264 if let Some(sri) = self.soft_retarget_interval.as_ref() {
265 let rt_date = date::subtract_interval(date::now(), sri)?;
266 let srt = date::to_iso(&rt_date);
267
268 log::info!("{self} using soft retarget time: {srt}");
269
270 self.soft_retarget_time = Some(srt);
271 }
272
273 let next_check_intvl = self
278 .next_check_interval
279 .as_deref()
280 .unwrap_or(retarget_intvl);
281
282 let next_check_date = date::add_interval(date::now(), next_check_intvl)?;
283 let next_check_time = date::to_iso(&next_check_date);
284
285 log::info!("{self} next check time {next_check_time}");
286
287 let query = eg::hash! {
290 "-and": [{
291 "close_start": {"<=": "now"},
292 "close_end": {">=": "now"}
293 }, {
294 "close_start": {"<=": next_check_time.as_str()},
295 "close_end": {">=": next_check_time.as_str()}
296 }]
297 };
298
299 let closed_orgs = self.editor().search("aoucd", query)?;
300
301 for co in closed_orgs {
302 self.closed_orgs.push(co["org_unit"].int()?);
303 }
304
305 for stat in self
306 .editor()
307 .search("ccs", eg::hash! {"hopeless_prone":"t"})?
308 {
309 self.hopeless_prone_statuses.push(stat["id"].int()?);
310 }
311
312 Ok(())
313 }
314
315 pub fn find_holds_to_target(&mut self) -> EgResult<Vec<i64>> {
319 let mut query = eg::hash! {
320 "select": {"ahr": ["id"]},
321 "from": "ahr",
322 "where": {
323 "capture_time": eg::NULL,
324 "fulfillment_time": eg::NULL,
325 "cancel_time": eg::NULL,
326 "frozen": "f"
327 },
328 "order_by": [
329 {"class": "ahr", "field": "selection_depth", "direction": "DESC"},
330 {"class": "ahr", "field": "request_time"},
331 {"class": "ahr", "field": "prev_check_time"}
332 ]
333 };
334
335 let start_time = if let Some(t) = self.soft_retarget_time.as_ref() {
340 t.as_str()
341 } else {
342 self.retarget_time.as_ref().unwrap().as_str()
343 };
344
345 query["where"]["-or"] = eg::array! [
346 {"prev_check_time": eg::NULL},
347 {"prev_check_time": {"<=": start_time}},
348 ];
349
350 let parallel = self.parallel_count;
351
352 if parallel > 1 {
356 query["from"] = eg::hash! {
359 "ahr": {
360 "rhrr": {
361 "fkey": "id",
362 "field": "id",
363 "join": {
364 "mmrsm": {
365 "field": "source",
366 "fkey": "bib_record"
367 }
368 }
369 }
370 }
371 };
372
373 let slot = self.parallel_slot - 1;
385
386 query["where"]["+mmrsm"] = eg::hash! {
387 "metarecord": {
388 "=": {
389 "transform": "mod",
390 "value": slot,
391 "params": [parallel]
392 }
393 }
394 };
395 }
396
397 if self.newest_first {
399 query["order_by"] = eg::array! [{
400 "class": "ahr",
401 "field": "request_time",
402 "direction": "DESC"
403 }];
404 }
405
406 let holds = self.editor().json_query(query)?;
410
411 log::info!("{self} found {} holds to target", holds.len());
412
413 Ok(holds.iter().map(|h| h["id"].int_required()).collect())
414 }
415
416 pub fn commit(&mut self) -> EgResult<()> {
417 if !self.transaction_manged_externally {
418 self.editor().commit()?;
422 }
423
424 Ok(())
425 }
426
427 fn update_hold(
432 &mut self,
433 context: &mut HoldTargetContext,
434 mut values: EgValue,
435 ) -> EgResult<()> {
436 for (k, v) in values.entries_mut() {
437 if k == "id" {
438 continue;
439 }
440 context.hold[k] = v.take();
441 }
442
443 self.editor().update(context.hold.clone())?;
444
445 context.hold = self
447 .editor()
448 .retrieve("ahr", context.hold_id)?
449 .ok_or("Cannot find hold")?;
450
451 Ok(())
452 }
453
454 fn hold_is_targetable(&mut self, context: &HoldTargetContext) -> bool {
457 let hold = &context.hold;
458
459 if hold["capture_time"].is_null()
460 && hold["cancel_time"].is_null()
461 && hold["fulfillment_time"].is_null()
462 && !hold["frozen"].boolish()
463 {
464 return true;
465 }
466
467 log::info!("{self} hold is not targetable");
468
469 false
470 }
471
472 fn hold_is_expired(&mut self, context: &mut HoldTargetContext) -> EgResult<bool> {
477 if let Some(etime) = context.hold["expire_time"].as_str() {
478 let ex_time = date::parse_datetime(etime)?;
479
480 if ex_time > date::now() {
481 return Ok(false);
483 }
484 } else {
485 return Ok(false);
487 }
488
489 let values = eg::hash! {
491 "cancel_time": "now",
492 "cancel_cause": 1, };
494
495 self.update_hold(context, values)?;
496
497 trigger::create_events_for_object(
499 self.editor(),
500 "hold_request.cancel.expire_no_target",
501 &context.hold,
502 context.pickup_lib,
503 None,
504 None,
505 false,
506 )?;
507
508 Ok(true)
509 }
510
511 fn get_hold_copies(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
514 let hold = &context.hold;
515
516 let hold_target = hold["target"].int()?;
517 let hold_type = hold["hold_type"].as_str().unwrap(); let org_unit = hold["selection_ou"].int()?;
519 let org_depth = hold["selection_depth"].as_int().unwrap_or(0); let mut query = eg::hash! {
522 "select": {
523 "acp": ["id", "status", "circ_lib"],
524 "ahr": ["current_copy"]
525 },
526 "from": {
527 "acp": {
528 "ahr": {
531 "type": "left",
532 "fkey": "id", "field": "current_copy",
534 "filter": {
535 "fulfillment_time": eg::NULL,
536 "cancel_time": eg::NULL,
537 "id": {"!=": context.hold_id},
538 }
539 }
540 }
541 },
542 "where": {
543 "+acp": {
544 "deleted": "f",
545 "circ_lib": {
546 "in": {
547 "select": {
548 "aou": [{
549 "transform": "actor.org_unit_descendants",
550 "column": "id",
551 "result_field": "id",
552 "params": [org_depth],
553 }],
554 },
555 "from": "aou",
556 "where": {"id": org_unit},
557 }
558 }
559 }
560 }
561 };
562
563 if hold_type != "R" && hold_type != "F" {
564 query["from"]["acp"]["acpl"] = eg::hash! {
569 "field": "id",
570 "filter": {"holdable": "t", "deleted": "f"},
571 "fkey": "location",
572 };
573
574 query["from"]["acp"]["ccs"] = eg::hash! {
575 "field": "id",
576 "filter": {"holdable": "t"},
577 "fkey": "status",
578 };
579
580 query["where"]["+acp"]["holdable"] = EgValue::from("t");
581
582 if hold["mint_condition"].boolish() {
583 query["where"]["+acp"]["mint_condition"] = EgValue::from("t");
584 }
585 }
586
587 if hold_type != "C" && hold_type != "I" && hold_type != "P" {
588 query["from"]["acp"]["acpm"] = eg::hash! {
592 "type": "left",
593 "field": "target_copy",
594 "fkey": "id"
595 };
596
597 query["where"]["+acpm"]["id"] = eg::NULL;
598 }
599
600 if hold_type == "C" || hold_type == "R" || hold_type == "F" {
602 query["where"]["+acp"]["id"] = EgValue::from(hold_target);
603 } else if hold_type == "V" {
604 query["where"]["+acp"]["call_number"] = EgValue::from(hold_target);
605 } else if hold_type == "P" {
606 query["from"]["acp"]["acpm"] = eg::hash! {
607 "field" : "target_copy",
608 "fkey" : "id",
609 "filter": {"part": hold_target},
610 };
611 } else if hold_type == "I" {
612 query["from"]["acp"]["sitem"] = eg::hash! {
613 "field" : "unit",
614 "fkey" : "id",
615 "filter": {"issuance": hold_target},
616 };
617 } else if hold_type == "T" {
618 query["from"]["acp"]["acn"] = eg::hash! {
619 "field" : "id",
620 "fkey" : "call_number",
621 "join": {
622 "bre": {
623 "field" : "id",
624 "filter": {"id": hold_target},
625 "fkey" : "record"
626 }
627 }
628 };
629 } else {
630 query["from"]["acp"]["acn"] = eg::hash! {
633 "field": "id",
634 "fkey": "call_number",
635 "join": {
636 "bre": {
637 "field": "id",
638 "fkey": "record",
639 "join": {
640 "mmrsm": {
641 "field": "source",
642 "fkey": "id",
643 "filter": {"metarecord": hold_target},
644 }
645 }
646 }
647 }
648 };
649
650 if let Some(formats) = hold["holdable_formats"].as_str() {
651 let query_ints = self.editor().json_query(eg::hash! {
655 "from": ["metabib.compile_composite_attr", formats]
656 })?;
657
658 if let Some(query_int) = query_ints.first() {
659 if let Some(qint) = query_int["metabib.compile_composite_attr"].as_str() {
662 query["from"]["acp"]["acn"]["join"]["bre"]["join"]["mravl"] = eg::hash! {
663 "field": "source",
664 "fkey": "id",
665 "filter": {"vlist": {"@@": qint}}
666 }
667 }
668 }
669 }
670 }
671
672 let mut found_copy = false;
673 let mut circ_libs: HashSet<i64> = HashSet::new();
674 context.copies = self
675 .editor()
676 .json_query(query)?
677 .iter()
678 .map(|c| {
679 let id = c["id"].int_required();
682 if id == context.find_copy {
683 found_copy = true;
684 }
685
686 let copy = PotentialCopy {
687 id,
688 status: c["status"].int_required(),
689 circ_lib: c["circ_lib"].int_required(),
690 proximity: -1,
691 already_targeted: !c["current_copy"].is_null(),
692 };
693
694 circ_libs.insert(copy.circ_lib);
695
696 copy
697 })
698 .collect();
699
700 context.eligible_copy_count = context.copies.len();
701 context.found_copy = found_copy;
702
703 log::info!("{self} {} potential copies", context.eligible_copy_count);
704
705 for lib in circ_libs.iter() {
707 log::info!("{self} pre-caching org settings for {lib}");
708 self.settings
709 .fetch_values_for_org(*lib, PRECACHE_ORG_SETTINGS)?;
710 }
711
712 Ok(())
713 }
714
715 fn update_copy_maps(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
718 log::info!("{self} creating {} hold copy maps", context.copies.len());
719
720 let ints = context
721 .copies
722 .iter()
723 .map(|c| format!("{}", c.id))
724 .collect::<Vec<String>>()
725 .join(",");
726
727 let ints = format!("{{{ints}}}");
729
730 let query = eg::hash! {
731 "from": [
732 "action.hold_request_regen_copy_maps",
733 context.hold_id,
734 ints
735 ]
736 };
737
738 self.editor().json_query(query).map(|_| ())
739 }
740
741 fn handle_hopeless_date(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
748 let marked_hopeless = !context.hold["hopeless_date"].is_null();
749
750 if context.copies.is_empty() && !marked_hopeless {
751 log::info!("{self} Marking hold as hopeless");
752 return self.update_hold(context, eg::hash! {"hopeless_date": "now"});
753 }
754
755 let we_have_hope = context
757 .copies
758 .iter()
759 .any(|c| !self.hopeless_prone_statuses.contains(&c.status));
760
761 if marked_hopeless {
762 if we_have_hope {
763 log::info!("{self} Removing hopeless date");
764 return self.update_hold(context, eg::hash! {"hopeless_date": eg::NULL});
765 }
766 } else if !we_have_hope {
767 log::info!("{self} Marking hold as hopeless");
768 return self.update_hold(context, eg::hash! {"hopeless_date": "now"});
769 }
770
771 Ok(())
772 }
773
774 fn hold_has_no_copies(
777 &mut self,
778 context: &mut HoldTargetContext,
779 force: bool,
780 process_recalls: bool,
781 ) -> EgResult<bool> {
782 if !force {
783 if !context.copies.is_empty() || context.valid_previous_copy.is_some() {
786 return Ok(false);
787 }
788 }
789
790 if process_recalls {
794 self.process_recalls(context)?;
797 }
798
799 let values = eg::hash! {
800 "current_copy": eg::NULL,
801 "prev_check_time": "now"
802 };
803
804 self.update_hold(context, values)?;
805
806 log::info!("{self} hold officially has no targetable copies");
807
808 Ok(true)
809 }
810
811 fn process_recalls(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
816 if context.recall_copies.is_empty() {
817 return Ok(());
818 }
819
820 let recall_threshold = self
821 .settings
822 .get_value_at_org("circ.holds.recall_threshold", context.pickup_lib)?;
823
824 let recall_threshold = match recall_threshold.to_string() {
825 Some(t) => t,
826 None => return Ok(()),
827 };
828
829 let return_interval = self
830 .settings
831 .get_value_at_org("circ.holds.recall_return_interval", context.pickup_lib)?;
832
833 let return_interval = match return_interval.to_string() {
834 Some(t) => t,
835 None => return Ok(()),
836 };
837
838 let copy_ids = context
839 .recall_copies
840 .iter()
841 .map(|c| c.id)
842 .collect::<Vec<i64>>();
843
844 let query = eg::hash! {
847 "target_copy": copy_ids,
848 "checkin_time": eg::NULL,
849 "duration": {">": recall_threshold.as_str()}
850 };
851
852 let ops = eg::hash! {
853 "order_by": [{"class": "circ", "field": "due_date"}],
854 "limit": 1
855 };
856
857 let mut circs = self.editor().search_with_ops("circ", query, ops)?;
858
859 let mut circ = match circs.pop() {
860 Some(c) => c,
861 None => {
863 log::info!("{self} no circulations to recall");
864 return Ok(());
865 }
866 };
867
868 log::info!("{self} recalling circ {}", circ["id"]);
869
870 let old_due_date = date::parse_datetime(circ["due_date"].as_str().unwrap())?;
871 let xact_start_date = date::parse_datetime(circ["xact_start"].as_str().unwrap())?;
872
873 let thresh_date = date::add_interval(xact_start_date, &recall_threshold)?;
874 let mut return_date = date::add_interval(date::now(), &return_interval)?;
875
876 if thresh_date > return_date {
879 return_date = thresh_date;
880 }
881
882 if return_date > old_due_date {
884 return_date = old_due_date;
885 }
886
887 circ["due_date"] = date::to_iso(&return_date).into();
888 circ["renewal_remaining"] = 0.into();
889
890 let mut fine_rules = self
891 .settings
892 .get_value_at_org("circ.holds.recall_fine_rules", context.pickup_lib)?
893 .clone();
894
895 log::debug!("{self} recall fine rules: {}", fine_rules);
896
897 if fine_rules.is_array() && fine_rules.len() == 3 {
899 circ["max_fine"] = fine_rules.pop();
900 circ["fine_interval"] = fine_rules.pop();
901 circ["recurring_fine"] = fine_rules.pop();
902 }
903
904 trigger::create_events_for_object(
910 self.editor(),
911 "circ.recall.target",
912 &circ,
913 circ["circ_lib"].int()?,
914 None,
915 None,
916 false,
917 )?;
918
919 self.editor().update(circ)?;
920
921 Ok(())
922 }
923
924 fn filter_copies_by_status_and_targeted(&self, context: &mut HoldTargetContext) {
927 let mut targetable = Vec::new();
928
929 while let Some(copy) = context.copies.pop() {
930 if copy.status == C::COPY_STATUS_CHECKED_OUT {
931 context.recall_copies.push(copy);
932 continue;
933 }
934
935 if copy.already_targeted {
936 context.otherwise_targeted_copies.push(copy);
937 continue;
938 }
939
940 if copy.status == C::COPY_STATUS_AVAILABLE || copy.status == C::COPY_STATUS_RESHELVING {
941 targetable.push(copy);
942 }
943 }
944
945 log::info!(
946 "{self} potential copies checked out={}, otherwise targeted={}, available={}",
947 context.recall_copies.len(),
948 context.otherwise_targeted_copies.len(),
949 targetable.len()
950 );
951
952 context.copies = targetable;
953 }
954
955 fn filter_closed_date_copies(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
958 let mut targetable = Vec::new();
959
960 while let Some(copy) = context.copies.pop() {
961 if self.closed_orgs.contains(©.circ_lib) {
962 let setting = if copy.circ_lib == context.pickup_lib {
963 "circ.holds.target_when_closed_if_at_pickup_lib"
964 } else {
965 "circ.holds.target_when_closed"
966 };
967
968 let value = self.settings.get_value_at_org(setting, copy.circ_lib)?;
969
970 if value.boolish() {
971 log::info!("{self} skipping copy at closed org unit {}", copy.circ_lib);
972 continue;
973 }
974 }
975
976 targetable.push(copy);
977 }
978
979 context.copies = targetable;
980
981 Ok(())
982 }
983
984 fn copy_is_permitted(
986 &mut self,
987 context: &mut HoldTargetContext,
988 copy_id: i64,
989 ) -> EgResult<bool> {
990 let result = holds::test_copy_for_hold(
991 self.editor(),
992 holds::CopyHoldParams {
993 patron_id: context.hold["usr"].int()?,
994 copy_id,
995 pickup_lib: context.pickup_lib,
996 request_lib: context.hold["request_lib"].int()?,
997 requestor: context.hold["requestor"].int()?,
998 is_retarget: true,
999 },
1000 None, true, )?;
1003
1004 if result.success() {
1005 log::info!("{self} copy {copy_id} is permitted");
1006 return Ok(true);
1007 }
1008
1009 if let Some(pos) = context.copies.iter().position(|c| c.id == copy_id) {
1011 log::info!("{self} copy {copy_id} is not permitted");
1012 context.copies.remove(pos);
1013 }
1014
1015 Ok(false)
1016 }
1017
1018 fn inspect_previous_target(&mut self, context: &mut HoldTargetContext) -> EgResult<bool> {
1023 let prev_copy = match context.hold["current_copy"].as_int() {
1024 Some(c) => c,
1025 None => return Ok(false), };
1027
1028 context.previous_copy_id = prev_copy;
1029
1030 if !context.copies.iter().any(|c| c.id == prev_copy) {
1031 return Ok(false);
1032 }
1033
1034 let mut soft_retarget = false;
1035 if self.soft_retarget_time.is_some() {
1036 if let Some(prev_check_time) = context.hold["prev_check_time"].as_str() {
1041 if let Some(retarget_time) = self.retarget_time.as_deref() {
1042 soft_retarget = prev_check_time > retarget_time;
1043 }
1044 }
1045 }
1046
1047 let mut retain_prev = false;
1048 if soft_retarget {
1049 if self.copy_is_permitted(context, prev_copy)? {
1051 log::info!("{self} retaining previous copy in soft-retarget");
1052 return Ok(true);
1053 }
1054
1055 log::info!("{self} previous copy is no longer viable. Retargeting");
1056 } else {
1057 retain_prev = true;
1059 }
1060
1061 if let Some(pos) = context.copies.iter().position(|c| c.id == prev_copy) {
1064 let copy = context.copies.remove(pos);
1065 if retain_prev {
1066 context.valid_previous_copy = Some(copy);
1067 }
1068 }
1069
1070 Ok(false)
1071 }
1072
1073 fn log_unfulfilled_hold(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
1076 if context.previous_copy_id == 0 {
1077 return Ok(());
1078 }
1079
1080 log::info!(
1081 "{self} logging unsuccessful capture of previous copy: {}",
1082 context.previous_copy_id
1083 );
1084
1085 let circ_lib = if let Some(copy) = context.valid_previous_copy.as_ref() {
1086 copy.circ_lib
1087 } else {
1088 let copy = self
1092 .editor()
1093 .retrieve("acp", context.previous_copy_id)?
1094 .ok_or(format!("Cannot find copy {}", context.previous_copy_id))?;
1095
1096 copy["circ_lib"].int()?
1097 };
1098
1099 let mut unful = eg::hash! {
1100 "hold": self.hold_id,
1101 "circ_lib": circ_lib,
1102 "current_copy": context.previous_copy_id
1103 };
1104
1105 unful.bless("aufh")?;
1106 self.editor().create(unful)?;
1107
1108 Ok(())
1109 }
1110
1111 fn attempt_force_recall_target(&self, context: &mut HoldTargetContext) {
1115 if let Some(ht) = context.hold["hold_type"].as_str() {
1116 if ht == "R" || ht == "F" {
1117 if let Some(c) = context.copies.first() {
1118 context.target = c.id;
1119 log::info!("{self} force/recall hold using copy {}", c.id);
1120 }
1121 }
1122 }
1123 }
1124
1125 fn attempt_to_find_copy(&mut self, context: &mut HoldTargetContext) -> EgResult<bool> {
1129 if context.target > 0 {
1130 return Ok(false);
1131 }
1132
1133 let max_loops = self
1134 .settings
1135 .get_value_at_org("circ.holds.max_org_unit_target_loops", context.pickup_lib)?;
1136
1137 if let Some(max) = max_loops.as_int() {
1138 if let Some(copy_id) = self.target_by_org_loops(context, max)? {
1139 context.target = copy_id;
1140 }
1141 } else {
1142 self.compile_weighted_proximity_map(context)?;
1145
1146 if let Some(copy_id) = self.find_nearest_copy(context)? {
1147 context.target = copy_id;
1148 }
1149 }
1150
1151 Ok(!context.hold["cancel_time"].is_null())
1152 }
1153
1154 fn find_nearest_copy(&mut self, context: &mut HoldTargetContext) -> EgResult<Option<i64>> {
1157 let inside_hard_stall = self.inside_hard_stall_interval(context)?;
1158 let mut have_local_copies = false;
1159
1160 if inside_hard_stall {
1163 have_local_copies = context
1164 .otherwise_targeted_copies
1165 .iter()
1166 .any(|c| c.proximity <= 0);
1167 }
1168
1169 let mut sorted_proximities: Vec<i64> = context.weighted_prox_map.keys().copied().collect();
1173
1174 sorted_proximities.sort();
1175
1176 let mut already_tested_copies: HashSet<i64> = HashSet::new();
1177
1178 for prox in sorted_proximities {
1179 let copy_ids = match context.weighted_prox_map.get_mut(&prox) {
1180 Some(list) => list,
1181 None => continue, };
1183
1184 if copy_ids.is_empty() {
1185 continue;
1186 }
1187
1188 if prox <= 0 {
1189 have_local_copies = true;
1190 }
1191
1192 if have_local_copies && inside_hard_stall && prox > 0 {
1193 break;
1202 }
1203
1204 let mut copy_ids = copy_ids.clone();
1207
1208 copy_ids.shuffle(&mut self.thread_rng);
1210
1211 for copy_id in copy_ids.iter() {
1212 if already_tested_copies.contains(copy_id) {
1213 continue;
1215 }
1216
1217 if self.copy_is_permitted(context, *copy_id)? {
1218 return Ok(Some(*copy_id));
1219 }
1220
1221 already_tested_copies.insert(*copy_id);
1222 }
1223 }
1224
1225 if have_local_copies && inside_hard_stall {
1226 if let Some(copy) = context.valid_previous_copy.as_ref() {
1230 if copy.proximity > 0 {
1231 context.valid_previous_copy = None;
1232 }
1233 }
1234 }
1235
1236 Ok(None)
1237 }
1238
1239 fn inside_hard_stall_interval(&mut self, context: &mut HoldTargetContext) -> EgResult<bool> {
1240 let interval = self
1241 .settings
1242 .get_value_at_org("circ.pickup_hold_stalling.hard", context.pickup_lib)?;
1243
1244 let interval = match interval.as_str() {
1245 Some(s) => s,
1246 None => return Ok(false),
1247 };
1248
1249 let req_time = context.hold["request_time"].as_str().unwrap();
1251 let req_time = date::parse_datetime(req_time)?;
1252
1253 let hard_stall_time = date::add_interval(req_time, interval)?;
1254
1255 log::info!("{self} hard stall deadline is/was {hard_stall_time}");
1256
1257 let inside = hard_stall_time > date::now();
1258
1259 log::info!("{self} still within hard stall interval? {inside}");
1260
1261 Ok(inside)
1262 }
1263
1264 fn target_by_org_loops(
1273 &mut self,
1274 context: &mut HoldTargetContext,
1275 max_loops: i64,
1276 ) -> EgResult<Option<i64>> {
1277 let query = eg::hash! {
1278 "select": {"aufhl": ["circ_lib", "count"]},
1279 "from": "aufhl",
1280 "where": {"hold": self.hold_id},
1281 "order_by": [{"class": "aufhl", "field": "count"}]
1282 };
1283
1284 let targeted_libs = self.editor().json_query(query)?;
1285
1286 let mut max_tried = 0;
1288 for lib in targeted_libs.iter() {
1289 let count = lib["count"].int()?;
1290 if count > max_tried {
1291 max_tried = count;
1292 }
1293 }
1294
1295 log::info!("{self} max lib attempts is {max_tried}");
1296 log::info!(
1297 "{self} {} libs have been targeted at least once",
1298 targeted_libs.len()
1299 );
1300
1301 let mut loop_iter = 0;
1306
1307 while loop_iter < max_loops {
1308 loop_iter += 1;
1309
1310 if context.copies.is_empty() {
1313 return Ok(None);
1314 }
1315
1316 let (iter_copies, remaining_copies) =
1317 self.get_copies_at_loop_iter(context, &targeted_libs, loop_iter - 1);
1318
1319 if iter_copies.is_empty() {
1320 context.copies = remaining_copies;
1322 continue;
1323 }
1324
1325 context.copies = iter_copies;
1326
1327 self.compile_weighted_proximity_map(context)?;
1330
1331 if let Some(copy) = self.find_nearest_copy(context)? {
1332 return Ok(Some(copy));
1336 }
1337
1338 context.copies = remaining_copies;
1341 }
1342
1343 if max_tried >= max_loops {
1344 self.handle_exceeds_target_loops(context)?;
1347 }
1348
1349 Ok(None)
1350 }
1351
1352 fn handle_exceeds_target_loops(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
1354 let values = eg::hash! {
1355 "cancel_time": "now",
1356 "cancel_cause": 1, };
1358
1359 self.update_hold(context, values)?;
1360
1361 trigger::create_events_for_object(
1362 self.editor(),
1363 "hold_request.cancel.expire_no_target",
1364 &context.hold,
1365 context.pickup_lib,
1366 None,
1367 None,
1368 false,
1369 )?;
1370
1371 Ok(())
1372 }
1373
1374 fn compile_weighted_proximity_map(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
1379 let query = eg::hash! {
1383 "select": {"ahcm": ["target_copy", "proximity"]},
1384 "from": "ahcm",
1385 "where": {"hold": self.hold_id}
1386 };
1387
1388 let copy_maps = self.editor().json_query(query)?;
1389
1390 let mut flat_map: HashMap<i64, i64> = HashMap::new();
1391
1392 for map in copy_maps.iter() {
1393 let copy_id = map["target_copy"].int()?;
1394 let proximity = map["proximity"].int()?;
1395 flat_map.insert(copy_id, proximity);
1396 }
1397
1398 let mut weighted: HashMap<i64, Vec<i64>> = HashMap::new();
1402 for copy in context.copies.iter_mut() {
1403 let prox = match flat_map.get(©.id) {
1404 Some(p) => *p, None => continue, };
1407
1408 copy.proximity = prox;
1409
1410 weighted.entry(prox).or_default();
1411
1412 let weight = self
1413 .settings
1414 .get_value_at_org("circ.holds.org_unit_target_weight", copy.circ_lib)?;
1415
1416 let weight = if weight.is_null() { 1 } else { weight.int()? };
1417
1418 if let Some(list) = weighted.get_mut(&prox) {
1419 for _ in 0..weight {
1420 list.push(copy.id);
1421 }
1422 }
1423 }
1424
1425 for copy in context.otherwise_targeted_copies.iter_mut() {
1429 if let Some(prox) = flat_map.get(©.id) {
1430 copy.proximity = *prox;
1431 }
1432 }
1433
1434 if let Some(copy) = context.valid_previous_copy.as_mut() {
1436 if let Some(prox) = flat_map.get(©.id) {
1437 copy.proximity = *prox;
1438 }
1439 }
1440
1441 context.weighted_prox_map = weighted;
1442
1443 Ok(())
1444 }
1445
1446 fn get_copies_at_loop_iter(
1452 &self,
1453 context: &mut HoldTargetContext,
1454 targeted_libs: &[EgValue],
1455 loop_iter: i64,
1456 ) -> (Vec<PotentialCopy>, Vec<PotentialCopy>) {
1457 let mut iter_copies = Vec::new();
1458 let mut remaining_copies = Vec::new();
1459
1460 while let Some(copy) = context.copies.pop() {
1461 let match_found = if loop_iter == 0 {
1462 !targeted_libs
1464 .iter()
1465 .any(|l| l["circ_lib"].int_required() == copy.circ_lib)
1466 } else {
1467 targeted_libs.iter().any(|l| {
1470 l["circ_lib"].int_required() == copy.circ_lib
1471 && l["count"].int_required() == loop_iter
1472 })
1473 };
1474
1475 if match_found {
1476 iter_copies.push(copy);
1477 } else {
1478 remaining_copies.push(copy);
1479 }
1480 }
1481
1482 log::info!(
1483 "{self} {} potential copies at max-loops iter level {loop_iter}. \
1484 {} remain to be tested at a higher loop iteration level",
1485 iter_copies.len(),
1486 remaining_copies.len()
1487 );
1488
1489 (iter_copies, remaining_copies)
1490 }
1491
1492 fn attempt_prev_copy_retarget(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
1495 if context.target > 0 {
1496 return Ok(());
1497 }
1498
1499 if let Some(copy_id) = context.valid_previous_copy.as_ref().map(|c| c.id) {
1500 log::info!(
1501 "Attempting to retarget previously targeted copy {}",
1502 copy_id
1503 );
1504
1505 if self.copy_is_permitted(context, copy_id)? {
1506 context.target = copy_id;
1507 }
1508 }
1509
1510 Ok(())
1511 }
1512
1513 fn apply_copy_target(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
1514 log::info!("{self} successfully targeted copy: {}", context.target);
1515
1516 let values = eg::hash! {
1517 "current_copy": context.target,
1518 "prev_check_time": "now"
1519 };
1520
1521 self.update_hold(context, values)
1522 }
1523
1524 pub fn target_hold(
1528 &mut self,
1529 hold_id: i64,
1530 find_copy: Option<i64>,
1531 ) -> EgResult<HoldTargetContext> {
1532 if !self.transaction_manged_externally {
1533 self.editor().xact_begin()?;
1534 }
1535
1536 let result = self.target_hold_internal(hold_id, find_copy.unwrap_or(0));
1537
1538 if result.is_ok() {
1539 let ctx = result.unwrap();
1540 self.commit()?;
1541 return Ok(ctx);
1542 }
1543
1544 self.editor().rollback()?;
1548
1549 let err = result.unwrap_err();
1550
1551 if let EgError::Debug(ref msg) = err {
1555 log::error!("{self} exited early with error message {msg}");
1556
1557 if let Some(mut evt) = self.editor().take_last_event() {
1558 evt.set_debug(msg);
1559 return Err(EgError::from_event(evt));
1560 }
1561 }
1562
1563 Err(err)
1564 }
1565
1566 fn target_hold_internal(
1569 &mut self,
1570 hold_id: i64,
1571 find_copy: i64,
1572 ) -> EgResult<HoldTargetContext> {
1573 self.hold_id = hold_id;
1574
1575 let hold = self
1576 .editor()
1577 .retrieve("ahr", hold_id)?
1578 .ok_or("No such hold")?;
1579
1580 let mut context = HoldTargetContext::new(hold_id, hold);
1581 let ctx = &mut context; ctx.find_copy = find_copy;
1583
1584 if !self.hold_is_targetable(ctx) {
1585 return Ok(context);
1586 }
1587
1588 if self.hold_is_expired(ctx)? {
1589 return Ok(context);
1591 }
1592
1593 self.get_hold_copies(ctx)?;
1594 self.update_copy_maps(ctx)?;
1595 self.handle_hopeless_date(ctx)?;
1596
1597 if self.hold_has_no_copies(ctx, false, false)? {
1598 return Ok(context);
1600 }
1601
1602 self.filter_copies_by_status_and_targeted(ctx);
1605 self.filter_closed_date_copies(ctx)?;
1606
1607 if self.inspect_previous_target(ctx)? {
1608 return Ok(context);
1610 }
1611
1612 self.log_unfulfilled_hold(ctx)?;
1613
1614 if self.hold_has_no_copies(ctx, false, true)? {
1615 return Ok(context);
1617 }
1618
1619 self.attempt_force_recall_target(ctx);
1626
1627 if self.attempt_to_find_copy(ctx)? {
1628 return Ok(context);
1630 }
1631
1632 self.attempt_prev_copy_retarget(ctx)?;
1633
1634 if ctx.target > 0 {
1635 self.apply_copy_target(ctx)?;
1637 ctx.success = true;
1638 } else {
1639 self.hold_has_no_copies(ctx, true, true)?;
1642 }
1643
1644 Ok(context)
1645 }
1646}