1use crate as eg;
2use eg::common::org;
3use eg::common::settings::Settings;
4use eg::common::targeter;
5use eg::common::transit;
6use eg::constants as C;
7use eg::date;
8use eg::event::{EgEvent, Overrides};
9use eg::Editor;
10use eg::EgError;
11use eg::EgResult;
12use eg::EgValue;
13use std::fmt;
14
15#[derive(Clone, Copy, Debug, PartialEq)]
16pub enum HoldType {
17 Copy,
18 Recall,
19 Force,
20 Issuance,
21 Volume,
22 Part,
23 Title,
24 Metarecord,
25}
26
27#[rustfmt::skip]
29impl From<HoldType> for &'static str {
30 fn from(t: HoldType) -> &'static str {
31 match t {
32 HoldType::Copy => "C",
33 HoldType::Recall => "R",
34 HoldType::Force => "F",
35 HoldType::Volume => "V",
36 HoldType::Issuance => "I",
37 HoldType::Part => "P",
38 HoldType::Title => "T",
39 HoldType::Metarecord => "M",
40 }
41 }
42}
43
44impl TryFrom<&str> for HoldType {
46 type Error = EgError;
47 fn try_from(code: &str) -> EgResult<HoldType> {
48 match code {
49 "C" => Ok(HoldType::Copy),
50 "R" => Ok(HoldType::Recall),
51 "F" => Ok(HoldType::Force),
52 "V" => Ok(HoldType::Volume),
53 "I" => Ok(HoldType::Issuance),
54 "P" => Ok(HoldType::Part),
55 "T" => Ok(HoldType::Title),
56 "M" => Ok(HoldType::Metarecord),
57 _ => Err(format!("No such hold type: {}", code).into()),
58 }
59 }
60}
61
62pub struct MinimalHold {
64 id: i64,
65 target: i64,
66 pickup_lib: i64,
67 hold_type: HoldType,
68 active: bool,
70}
71
72impl MinimalHold {
73 pub fn id(&self) -> i64 {
74 self.id
75 }
76 pub fn target(&self) -> i64 {
77 self.target
78 }
79 pub fn pickup_lib(&self) -> i64 {
80 self.pickup_lib
81 }
82 pub fn hold_type(&self) -> HoldType {
83 self.hold_type
84 }
85 pub fn active(&self) -> bool {
86 self.active
87 }
88}
89
90impl fmt::Display for MinimalHold {
91 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
92 let t: &str = self.hold_type.into();
93 write!(
94 f,
95 "hold id={} target={} pickup_lib={} hold_type={} active={}",
96 self.id, self.target, self.pickup_lib, t, self.active
97 )
98 }
99}
100
101pub fn calc_hold_shelf_expire_time(
104 editor: &mut Editor,
105 hold: &EgValue,
106 start_time: Option<&str>,
107) -> EgResult<Option<String>> {
108 let pickup_lib = hold["pickup_lib"].int()?;
109
110 let mut settings = Settings::new(editor);
111 let interval =
112 settings.get_value_at_org("circ.holds.default_shelf_expire_interval", pickup_lib)?;
113
114 let interval = match interval.as_str() {
115 Some(i) => i,
116 None => return Ok(None), };
118
119 let start_time = if let Some(st) = start_time {
120 date::parse_datetime(st)?
121 } else {
122 date::now_local()
123 };
124
125 let mut start_time = date::add_interval(start_time, interval)?;
126 let org_info = org::next_open_date(editor, pickup_lib, &start_time)?;
127
128 if let org::OrgOpenState::OpensOnDate(open_on) = org_info {
129 start_time = date::set_hms(&open_on, 23, 59, 59)?;
132 }
133
134 Ok(Some(date::to_iso(&start_time)))
135}
136
137pub fn captured_hold_for_copy(editor: &mut Editor, copy_id: i64) -> EgResult<Option<EgValue>> {
140 let query = eg::hash! {
141 current_copy: copy_id,
142 capture_time: {"!=": eg::NULL},
143 fulfillment_time: eg::NULL,
144 cancel_time: eg::NULL,
145 };
146
147 Ok(editor.search("ahr", query)?.pop())
148}
149
150pub fn find_nearest_permitted_hold(
154 editor: &mut Editor,
155 copy_id: i64,
156 check_only: bool,
157) -> EgResult<Option<(EgValue, Vec<i64>)>> {
158 let mut retarget: Vec<i64> = Vec::new();
159
160 let flesh = eg::hash! {
162 flesh: 1,
163 flesh_fields: {
164 "acp": ["call_number"],
165 }
166 };
167
168 let copy = match editor.retrieve_with_ops("acp", copy_id, flesh)? {
169 Some(c) => c,
170 None => Err(editor.die_event())?,
171 };
172
173 let query = eg::hash! {
174 "current_copy": copy_id,
175 "cancel_time": eg::NULL,
176 "capture_time": eg::NULL,
177 };
178
179 let mut old_holds = editor.search("ahr", query)?;
180
181 let mut settings = Settings::new(editor);
182 let hold_stall_intvl = settings.get_value("circ.hold_stalling.soft")?;
183
184 let params = vec![
185 EgValue::from(editor.requestor_ws_ou()),
186 copy.clone(),
187 EgValue::from(100),
188 hold_stall_intvl.clone(),
189 ];
190
191 let best_hold_results = editor.client_mut().send_recv_one(
193 "open-ils.storage",
194 "open-ils.storage.action.hold_request.nearest_hold.atomic",
195 params,
196 )?;
197
198 let mut best_holds: Vec<i64> = Vec::new();
200 if let Some(bhr) = best_hold_results {
201 for h in bhr.members() {
202 best_holds.push(h.int()?);
203 }
204 }
205
206 for old_hold in old_holds.iter() {
208 let old_id = old_hold.id()?;
209 if !best_holds.contains(&old_id) {
210 best_holds.push(old_id);
211 }
212 }
213
214 if best_holds.is_empty() {
215 log::info!("Found no suitable holds for item {}", copy["barcode"]);
216 return Ok(None);
217 }
218
219 let mut best_hold = None;
220
221 for hold_id in best_holds {
222 log::info!(
223 "Checking if hold {hold_id} is permitted for copy {}",
224 copy["barcode"]
225 );
226
227 let hold = editor.retrieve("ahr", hold_id)?.unwrap(); let hold_type = hold["hold_type"].as_str().unwrap(); if hold_type == "R" || hold_type == "F" {
230 best_hold = Some(hold);
232 break;
233 }
234
235 let result = test_copy_for_hold(
236 editor,
237 CopyHoldParams {
238 patron_id: hold["usr"].int()?,
239 copy_id,
240 pickup_lib: hold["pickup_lib"].int()?,
241 request_lib: hold["request_lib"].int()?,
242 requestor: hold["requestor"].int()?,
243 is_retarget: true, },
245 None, true, )?;
248
249 if result.success {
250 best_hold = Some(hold);
251 break;
252 }
253 }
254
255 let mut targeted_hold = match best_hold {
256 Some(h) => h,
257 None => {
258 log::info!("No suitable holds found for copy {}", copy["barcode"]);
259 return Ok(None);
260 }
261 };
262
263 log::info!(
264 "Best hold {} found for copy {}",
265 targeted_hold["id"],
266 copy["barcode"]
267 );
268
269 if check_only {
270 return Ok(Some((targeted_hold, retarget)));
271 }
272
273 targeted_hold["current_copy"] = EgValue::from(copy_id);
275 editor.update(targeted_hold.clone())?;
276
277 for mut hold in old_holds.drain(..) {
279 if hold["id"] == targeted_hold["id"] {
280 continue;
281 }
282 let hold_id = hold.id()?;
283
284 hold["current_copy"].take();
285 hold["prev_check_time"].take();
286
287 editor.update(hold)?;
288 retarget.push(hold_id);
289 }
290
291 Ok(Some((targeted_hold, retarget)))
292}
293
294#[derive(Default)]
295pub struct HoldPermitResult {
296 matchpoint: Option<i64>,
297 fail_part: Option<String>,
298 mapped_event: Option<EgEvent>,
299 failed_override: Option<EgEvent>,
300}
301
302impl HoldPermitResult {
303 pub fn new() -> HoldPermitResult {
304 Default::default()
305 }
306}
307
308pub struct TestCopyForHoldResult {
309 success: bool,
312
313 permit_results: Vec<HoldPermitResult>,
315
316 age_protect_only: bool,
318}
319
320impl TestCopyForHoldResult {
321 pub fn success(&self) -> bool {
322 self.success
323 }
324 pub fn permit_results(&self) -> &Vec<HoldPermitResult> {
325 &self.permit_results
326 }
327 pub fn age_protect_only(&self) -> bool {
328 self.age_protect_only
329 }
330}
331
332pub struct CopyHoldParams {
333 pub patron_id: i64,
334 pub copy_id: i64,
335 pub pickup_lib: i64,
336 pub request_lib: i64,
337 pub requestor: i64,
338 pub is_retarget: bool,
339}
340
341pub fn test_copy_for_hold(
343 editor: &mut Editor,
344 params: CopyHoldParams,
345 overrides: Option<Overrides>,
346 check_only: bool,
350) -> EgResult<TestCopyForHoldResult> {
351 let patron_id = params.patron_id;
352 let copy_id = params.copy_id;
353 let pickup_lib = params.pickup_lib;
354 let request_lib = params.request_lib;
355 let requestor = params.requestor;
356 let is_retarget = params.is_retarget;
357
358 let mut result = TestCopyForHoldResult {
359 success: false,
360 permit_results: Vec::new(),
361 age_protect_only: false,
362 };
363
364 let db_func = match is_retarget {
365 true => "action.hold_retarget_permit_test",
366 false => "action.hold_request_permit_test",
367 };
368
369 let query = eg::hash! {
370 "from": [
371 db_func,
372 pickup_lib,
373 request_lib,
374 copy_id,
375 patron_id,
376 requestor,
377 ]
378 };
379
380 let db_results = editor.json_query(query)?;
381
382 if let Some(row) = db_results.first() {
383 if row["success"].boolish() {
385 let mut res = HoldPermitResult::new();
386
387 res.matchpoint = row["matchpoint"].as_int(); result.permit_results.push(res);
389 result.success = true;
390
391 return Ok(result);
392 }
393 }
394
395 if check_only && overrides.is_none() {
396 return Ok(result);
398 }
399
400 let mut pending_results = Vec::new();
401
402 for res in db_results.iter() {
403 let fail_part = match res["fail_part"].as_str() {
404 Some(s) => s,
405 None => continue, };
407
408 let matchpoint = db_results[0]["matchpoint"].as_int(); let mut res = HoldPermitResult::new();
411 res.fail_part = Some(fail_part.to_string());
412 res.matchpoint = matchpoint;
413
414 let evtcode = match fail_part {
416 "config.hold_matrix_test.holdable" => "ITEM_NOT_HOLDABLE",
417 "item.holdable" => "ITEM_NOT_HOLDABLE",
418 "location.holdable" => "ITEM_NOT_HOLDABLE",
419 "status.holdable" => "ITEM_NOT_HOLDABLE",
420 "transit_range" => "ITEM_NOT_HOLDABLE",
421 "no_matchpoint" => "NO_POLICY_MATCHPOINT",
422 "config.hold_matrix_test.max_holds" => "MAX_HOLDS",
423 "config.rule_age_hold_protect.prox" => "ITEM_AGE_PROTECTED",
424 _ => fail_part,
425 };
426
427 let mut evt = EgEvent::new(evtcode);
428 evt.set_payload(eg::hash! {
429 "fail_part": fail_part,
430 "matchpoint": matchpoint,
431 });
432
433 res.mapped_event = Some(evt);
434 pending_results.push(res);
435 }
436
437 if pending_results.is_empty() {
438 return Ok(result);
440 }
441
442 let mut has_failure = false;
443 let mut has_age_protect = false;
444 for mut pending_result in pending_results.drain(0..) {
445 let evt = pending_result.mapped_event.as_ref().unwrap();
446
447 if !has_age_protect {
448 has_age_protect = evt.textcode() == "ITEM_AGE_PROTECTED";
449 }
450
451 let try_override = if let Some(ov) = overrides.as_ref() {
452 match ov {
453 Overrides::All => true,
454 Overrides::Events(ref list) => list
455 .iter()
456 .map(|e| e.as_str())
457 .collect::<Vec<&str>>()
458 .contains(&evt.textcode()),
459 }
460 } else {
461 false
462 };
463
464 if try_override {
465 let permission = format!("{}.override", evt.textcode());
466 log::debug!("Checking permission to verify copy for hold: {permission}");
467
468 if editor.allowed(&permission)? {
469 log::debug!("Override succeeded for {permission}");
470 } else {
471 has_failure = true;
472 if let Some(e) = editor.last_event() {
473 pending_result.failed_override = Some(e.clone());
475 }
476 }
477 }
478
479 result.permit_results.push(pending_result);
480 }
481
482 result.age_protect_only = has_age_protect && result.permit_results.len() == 1;
483
484 result.success = !has_failure;
487
488 Ok(result)
489}
490
491pub fn retarget_holds(editor: &Editor, hold_ids: &[i64]) -> EgResult<()> {
497 let mut editor = editor.clone();
498 let mut hold_targeter = targeter::HoldTargeter::new(&mut editor);
499
500 for hold_id in hold_ids {
501 hold_targeter.target_hold(*hold_id, None)?;
502 }
503
504 Ok(())
505}
506
507pub fn retarget_hold(editor: &mut Editor, hold_id: i64) -> EgResult<targeter::HoldTargetContext> {
511 let mut targeter = targeter::HoldTargeter::new(editor);
512
513 targeter.set_transaction_manged_externally(true);
515
516 targeter.init()?;
517
518 let ctx = targeter.target_hold(hold_id, None)?;
519
520 Ok(ctx)
521}
522
523pub fn reset_hold(editor: &mut Editor, hold_id: i64) -> EgResult<targeter::HoldTargetContext> {
527 log::info!("Resetting hold {hold_id}");
528
529 let mut hold = editor
530 .retrieve("ahr", hold_id)?
531 .ok_or_else(|| editor.die_event())?;
532
533 if !hold["capture_time"].is_null() && !hold["current_copy"].is_null() {
535 let mut copy = editor
536 .retrieve("acp", hold["current_copy"].clone())?
537 .ok_or_else(|| editor.die_event())?;
538
539 let copy_status = copy["status"].int()?;
540
541 if copy_status == C::COPY_STATUS_ON_HOLDS_SHELF {
542 copy["status"] = EgValue::from(C::COPY_STATUS_RESHELVING);
543 copy["editor"] = EgValue::from(editor.requestor_id()?);
544 copy["edit_date"] = EgValue::from("now");
545
546 editor.update(copy)?;
547 } else if copy_status == C::COPY_STATUS_IN_TRANSIT {
548 let query = eg::hash! {
549 "hold": hold_id,
550 "cancel_time": eg::NULL,
551 };
552
553 if let Some(ht) = editor.search("ahtc", query)?.pop() {
554 transit::cancel_transit(editor, ht.id()?, true)?;
555 }
556 }
557 }
558
559 hold["capture_time"].take();
560 hold["current_copy"].take();
561 hold["shelf_time"].take();
562 hold["shelf_expire_time"].take();
563 hold["current_shelf_lib"].take();
564
565 editor.update(hold)?;
566
567 retarget_hold(editor, hold_id)
568}
569
570pub fn json_query_order_by_targetable() -> EgValue {
572 eg::array! [
573 {"class": "pgt", "field": "hold_priority"},
574 {"class": "ahr", "field": "cut_in_line",
575 "direction": "desc", "transform": "coalesce", params: vec!["f"]},
576 {"class": "ahr", "field": "selection_depth", "direction": "desc"},
577 {"class": "ahr", "field": "request_time"}
578 ]
579}
580
581pub fn related_to_copy(
588 editor: &mut Editor,
589 copy_id: i64,
590 pickup_lib: Option<i64>,
591 frozen: Option<bool>,
592 usr: Option<i64>,
593 on_shelf: Option<bool>,
594) -> EgResult<Vec<MinimalHold>> {
595 let from = eg::hash! {
611 "acp": {
612 "acn": {
613 "join": {
614 "mmrsm": {
615 "fkey": "record",
617 "field": "source",
618 "join": {
619 "rhrr": {
620 "fkey": "source",
622 "field": "bib_record",
623 "join": {
624 "ahr": {
625 "join": {
626 "au": {
627 "field": "id",
628 "fkey": "usr",
629 "join": "pgt"
630 }
631 }
632 }
633 }
634 }
635 }
636 }
637 }
638 }
639 }
640 };
641
642 let mut query = eg::hash! {
643 "select": {
644 "ahr": [
645 "id",
646 "target",
647 "hold_type",
648 "selection_depth",
649 "request_time",
650 "cut_in_line",
651 "pickup_lib",
652 ],
653 "pgt": ["hold_priority"]
654 },
655 "from": from,
656 "where": {
657 "+acp": {"id": copy_id},
658 "+ahr": {
659 "cancel_time": eg::NULL,
660 "fulfillment_time" => eg::NULL,
661 }
662 },
663 "order_by": json_query_order_by_targetable(),
664 };
665
666 if let Some(v) = pickup_lib {
667 query["where"]["+ahr"]["pickup_lib"] = EgValue::from(v);
668 }
669
670 if let Some(v) = usr {
671 query["where"]["+ahr"]["usr"] = EgValue::from(v);
672 }
673
674 if let Some(v) = frozen {
675 let s = if v { "t" } else { "f" };
676 query["where"]["+ahr"]["frozen"] = EgValue::from(s);
677 }
678
679 if let Some(v) = on_shelf {
681 if v {
682 query["where"]["+ahr"]["current_shelf_lib"] = eg::hash! {"=": {"+ahr": "pickup_lib"}};
683 } else {
684 query["where"]["+ahr"]["-or"] = eg::array! [
685 {"current_shelf_lib": eg::NULL},
686 {"current_shelf_lib": {"!=": {"+ahr": "pickup_lib"}}}
687 ];
688 }
689 }
690
691 let mut list = Vec::new();
692 for val in editor.json_query(query)? {
693 let hold_type = HoldType::try_from(val["hold_type"].as_str().unwrap()).unwrap();
695
696 let h = MinimalHold {
697 id: val.id()?,
698 target: val["target"].int()?,
699 pickup_lib: val["pickup_lib"].int()?,
700 hold_type,
701 active: true,
702 };
703
704 list.push(h);
705 }
706
707 Ok(list)
708}
709
710pub fn record_hold_counts(
715 editor: &mut Editor,
716 rec_id: i64,
717 pickup_lib_descendant: Option<i64>,
718) -> EgResult<i64> {
719 let mut query = eg::hash! {
720 "select": {
721 "ahr": [{"column": "id", "transform": "count", "alias": "count"}]
722 },
723 "from": {
724 "ahr": {
725 "rhrr": {
726 "fkey": "id",
727 "field": "id",
728 }
729 }
730 },
731 "where": {
732 "+ahr": {
733 "cancel_time": EgValue::Null,
734 "fulfillment_time": EgValue::Null,
735 },
736 "+rhrr": {
737 "bib_record": rec_id
738 }
739 }
740 };
741
742 if let Some(plib) = pickup_lib_descendant {
743 query["where"]["+ahr"]["pickup_lib"] = eg::hash! {
744 "in": {
745 "select": {
746 "aou": [{
747 "column": "id",
748 "transform": "actor.org_unit_descendants",
749 "result_field": "id"
750 }]
751 },
752 "from": "aou",
753 "where": {"id": plib}
754 }
755 }
756 }
757
758 let result = editor
759 .json_query(query)?
760 .pop()
761 .ok_or("record_hold_counts() return no results")?;
762
763 result["count"].int()
764}
765
766pub fn record_has_holdable_copy(editor: &mut Editor, rec_id: i64, is_meta: bool) -> EgResult<bool> {
769 let key = if is_meta { "metarecord" } else { "record" };
770 let func = format!("asset.{key}_has_holdable_copy");
771
772 let query = eg::hash! {"from": [func.as_str(), rec_id]};
773
774 let data = editor
775 .json_query(query)?
776 .pop()
777 .ok_or("json_query returned zero results")?;
778
779 Ok(data[&func].boolish())
780}