evergreen/common/
holds.rs

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/// let s: &str = hold_type.into();
28#[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
44/// let hold_type: HoldType = "T".into();
45impl 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
62/// Just enough hold information to make business decisions.
63pub struct MinimalHold {
64    id: i64,
65    target: i64,
66    pickup_lib: i64,
67    hold_type: HoldType,
68    /// active == not canceled, not fulfilled, and not frozen.
69    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
101/// Returns an ISO date string if a shelf time was calculated, None
102/// if holds do not expire on the shelf.
103pub 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), // hold never expire on-shelf.
117    };
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        // Org unit is closed on the calculated shelf expire date.
130        // Extend the expire date to the end of the next open day.
131        start_time = date::set_hms(&open_on, 23, 59, 59)?;
132    }
133
134    Ok(Some(date::to_iso(&start_time)))
135}
136
137/// Returns the captured, unfulfilled, uncanceled hold that
138/// targets the provided copy.
139pub 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
150/// Returns the captured hold if found and a list of hold IDs that
151/// will need to be retargeted, since they previously targeted the
152/// provided copy.
153pub 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    // Fetch the appropriatly fleshed copy.
161    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    // best_holds is an array of hold IDs.
192    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    // Map the hold IDs to numbers.
199    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    // Holds that already target this copy are still in the game.
207    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(); // required
228        let hold_type = hold["hold_type"].as_str().unwrap(); // required
229        if hold_type == "R" || hold_type == "F" {
230            // These hold types do not require verification
231            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, // is_retarget
244            },
245            None, // overrides
246            true, // check_only
247        )?;
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    // Target the copy
274    targeted_hold["current_copy"] = EgValue::from(copy_id);
275    editor.update(targeted_hold.clone())?;
276
277    // Retarget any other holds that currently target this copy.
278    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    /// True if the permit call returned a success or we were able
310    /// to override all failure events.
311    success: bool,
312
313    /// Details on the individual permit results.
314    permit_results: Vec<HoldPermitResult>,
315
316    /// True if age-protect is the only blocking factor.
317    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
341/// Test if a hold can be used to fill a hold.
342pub fn test_copy_for_hold(
343    editor: &mut Editor,
344    params: CopyHoldParams,
345    overrides: Option<Overrides>,
346    // Exit as soon as we know if the permit was allowed or not.
347    // If overrides are provided, this flag is ignored, since
348    // overrides require the function process all the things.
349    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 the first result is a success, we're done.
384        if row["success"].boolish() {
385            let mut res = HoldPermitResult::new();
386
387            res.matchpoint = row["matchpoint"].as_int(); // Option
388            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        // Permit test failed.  No overrides needed.
397        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, // Should not happen.
406        };
407
408        let matchpoint = db_results[0]["matchpoint"].as_int(); // Option
409
410        let mut res = HoldPermitResult::new();
411        res.fail_part = Some(fail_part.to_string());
412        res.matchpoint = matchpoint;
413
414        // Map some newstyle fail parts to legacy event codes.
415        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        // This should not happen, but cannot go unchecked.
439        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                    // should be set.
474                    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    // If all events were successfully overridden, then the end
485    // result is a success.
486    result.success = !has_failure;
487
488    Ok(result)
489}
490
491/// Retarget a batch of holds.
492///
493/// Each hold is targeted within its own transaction, managed by
494/// the targeter.  To target holds within an existing transaction,
495/// see `retarget_hold()`.
496pub 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
507/// Target a single hold.
508///
509/// Uses an externally managed Editor transaction.
510pub fn retarget_hold(editor: &mut Editor, hold_id: i64) -> EgResult<targeter::HoldTargetContext> {
511    let mut targeter = targeter::HoldTargeter::new(editor);
512
513    // We're managing the editor transaction.
514    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
523/// Reset a hold and retarget it.
524///
525/// Uses an externally managed Editor transaction.
526pub 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    // Resetting captured holds requires a little more care.
534    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
570/// json_query order by clause for sorting holds by next to be targeted.
571pub 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
581/// Returns a list of non-canceled / non-fulfilled holds linked to the
582/// provided copy by virtue of sharing a metarecord, IOW, holds that
583/// could potentially target the provided copy.  Under the metarecord
584/// umbrella, this covers all hold types.
585///
586/// The list is sorted in the order they would ideally be fulfilled.
587pub 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    // "rhrr" / reporter.hold_request_record calculates the bib record
596    // linked to a hold regardless of hold type in advance for us.
597    // Leverage that.  It's fast.
598    //
599    // TODO reporter.hold_request_record is not currently updated
600    // when items/call numbers are transferred to another call
601    // number/record.
602
603    // copy
604    //   => call_number
605    //     => metarecord source map
606    //       => reporter.hold_request_record
607    //         => hold
608    //           => user
609    //             => profile group
610    let from = eg::hash! {
611        "acp": {
612            "acn": {
613                "join": {
614                    "mmrsm": {
615                        // ON acn.record = mmrsm.source
616                        "fkey": "record",
617                        "field": "source",
618                        "join": {
619                            "rhrr": {
620                                // ON mmrsm.source = rhrr.bib_record
621                                "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    // Limiting on wether current_shelf_lib == pickup_lib.
680    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        // We know the hold type returned from the database is valid.
694        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
710/// Count of open holds that target a bib record or any of its
711/// associated call numbers, copies, etc.
712///
713/// TODO metarecords
714pub 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
766/// Returns true if the record/metarecord in question has at least
767/// one holdable copy.
768pub 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}