evergreen/common/
checkin.rs

1use crate as eg;
2use chrono::Timelike;
3use eg::common::billing;
4use eg::common::circulator::{CircOp, Circulator};
5use eg::common::holds;
6use eg::common::penalty;
7use eg::common::targeter;
8use eg::common::transit;
9use eg::constants as C;
10use eg::date;
11use eg::event::EgEvent;
12use eg::result::EgResult;
13use eg::EgValue;
14use std::collections::HashSet;
15
16/// Performs item checkins
17impl Circulator<'_> {
18    /// Checkin an item.
19    ///
20    /// Returns Ok(()) if the active transaction should be committed and
21    /// Err(EgError) if the active transaction should be rolled backed.
22    pub fn checkin(&mut self) -> EgResult<()> {
23        if self.circ_op == CircOp::Unset {
24            self.circ_op = CircOp::Checkin;
25        }
26
27        self.init()?;
28
29        if !self.is_renewal() && !self.editor.allowed_at("COPY_CHECKIN", self.circ_lib)? {
30            return Err(self.editor().die_event());
31        }
32
33        log::info!("{self} starting checkin");
34
35        self.basic_copy_checks()?;
36
37        self.fix_broken_transit_status()?;
38        self.check_transit_checkin_interval()?;
39        self.checkin_retarget_holds()?;
40        self.cancel_transit_if_circ_exists()?;
41        self.hold_revert_sanity_checks()?;
42        self.set_dont_change_lost_zero()?;
43        self.set_can_float()?;
44        self.do_inventory_update()?;
45
46        if self.check_is_on_holds_shelf()? {
47            // Item is resting cozily on the holds shelf. Leave it be.
48            return Ok(());
49        }
50
51        self.load_system_copy_alerts()?;
52        self.load_runtime_copy_alerts()?;
53        self.check_copy_alerts()?;
54
55        self.check_claims_returned();
56        self.check_circ_deposit(false)?;
57        self.try_override_events()?;
58
59        if self.exit_early {
60            return Ok(());
61        }
62
63        if self.circ.is_some() {
64            self.checkin_handle_circ()?;
65        } else if self.transit.is_some() {
66            self.checkin_handle_transit()?;
67            self.checkin_handle_received_hold()?;
68        } else if self.copy_status() == C::COPY_STATUS_IN_TRANSIT {
69            log::warn!("{self} copy is in-transit but there is no transit");
70            self.reshelve_copy(true)?;
71        }
72
73        if self.exit_early {
74            return Ok(());
75        }
76
77        if self.is_renewal() {
78            self.finish_fines_and_voiding()?;
79            self.add_event_code("SUCCESS");
80            return Ok(());
81        }
82
83        if self.revert_hold_fulfillment()? {
84            return Ok(());
85        }
86
87        // Circulations and transits are now closed where necessary.
88        // Now see if this copy can fulfill a hold or needs to be
89        // routed to a different location.
90
91        let mut item_is_needed = false;
92        if self.get_option_bool("noop") {
93            if self.get_option_bool("can_float") {
94                // As noted in the Perl, it may be unexpected that
95                // floating items are modified during NO-OP checkins,
96                // but the behavior is retained for backwards compat.
97                self.update_copy(eg::hash! {"circ_lib": self.circ_lib})?;
98            }
99        } else {
100            item_is_needed = self.try_to_capture()?;
101            if !item_is_needed {
102                self.try_to_transit()?;
103            }
104        }
105
106        if !self.handle_claims_never()? && !item_is_needed {
107            self.reshelve_copy(false)?;
108        }
109
110        if self.editor().has_pending_changes() {
111            if self.events.is_empty() {
112                self.add_event(EgEvent::success());
113            }
114        } else {
115            self.add_event(EgEvent::new("NO_CHANGE"));
116        }
117
118        self.finish_fines_and_voiding()?;
119
120        if self.patron.is_some() {
121            penalty::calculate_penalties(self.editor, self.patron_id, self.circ_lib, None)?;
122        }
123
124        self.cleanup_events();
125        self.flesh_checkin_events()?;
126
127        Ok(())
128    }
129
130    /// Returns true if claims-never-checked-out handling occurred.
131    fn handle_claims_never(&mut self) -> EgResult<bool> {
132        if !self.get_option_bool("claims_never_checked_out") {
133            return Ok(false);
134        }
135
136        let circ = match self.circ.as_ref() {
137            Some(c) => c, // should be set at this point
138            None => return Ok(false),
139        };
140
141        if !self
142            .settings
143            .get_value_at_org(
144                "circ.claim_never_checked_out.mark_missing",
145                circ["circ_lib"].int()?,
146            )?
147            .boolish()
148        {
149            return Ok(false);
150        }
151
152        // Configured to mark claims never checked out as Missing.
153        // Note to self: this would presumably be a circ-id based
154        // checkin instead of a copy id/barcode checkin.
155
156        let next_status = match self.options.get("next_copy_status") {
157            Some(s) => s.int()?,
158            None => C::COPY_STATUS_MISSING,
159        };
160
161        self.update_copy(eg::hash! {"status": next_status})?;
162
163        Ok(true)
164    }
165
166    /// What value did the caller provide for the "capture" option, if any.
167    fn capture_state(&self) -> &str {
168        match self.options.get("capture") {
169            Some(c) => c.as_str().unwrap_or(""),
170            None => "",
171        }
172    }
173
174    /// Load the open transit and make sure our copy is in the right
175    /// status if there's a matching transit.
176    fn fix_broken_transit_status(&mut self) -> EgResult<()> {
177        let query = eg::hash! {
178            target_copy: self.copy()["id"].clone(),
179            dest_recv_time: EgValue::Null,
180            cancel_time: EgValue::Null,
181        };
182
183        let mut results = self.editor().search("atc", query)?;
184
185        let transit = match results.pop() {
186            Some(t) => t,
187            None => return Ok(()),
188        };
189
190        if self.copy_status() != C::COPY_STATUS_IN_TRANSIT {
191            log::warn!("{self} Copy has an open transit, but incorrect status");
192            let changes = eg::hash! {status: C::COPY_STATUS_IN_TRANSIT};
193            self.update_copy(changes)?;
194        }
195
196        self.transit = Some(transit);
197
198        Ok(())
199    }
200
201    /// If a copy goes into transit and is then checked in before the
202    /// transit checkin interval has expired, push an event onto the
203    /// overridable events list.
204    fn check_transit_checkin_interval(&mut self) -> EgResult<()> {
205        if self.copy_status() != C::COPY_STATUS_IN_TRANSIT {
206            // We only care about in-transit items.
207            return Ok(());
208        }
209
210        let interval = self
211            .settings
212            .get_value("circ.transit.min_checkin_interval")?;
213
214        if interval.is_null() {
215            // No checkin interval defined.
216            return Ok(());
217        }
218
219        let transit = match self.transit.as_ref() {
220            Some(t) => t,
221            None => {
222                log::warn!("Copy has in-transit status but no matching transit!");
223                return Ok(());
224            }
225        };
226
227        if transit["source"] == transit["dest"] {
228            // Checkin interval does not apply to transits that aren't
229            // actually going anywhere.
230            return Ok(());
231        }
232
233        // Coerce the interval into a string just in case it arrived as a number.
234        let interval = interval.string()?;
235
236        // source_send_time is a known non-null string value.
237        let send_time_str = transit["source_send_time"].as_str().unwrap();
238        let send_time = date::parse_datetime(send_time_str)?;
239
240        let horizon = date::add_interval(send_time, &interval)?;
241
242        if horizon > date::now() {
243            self.add_event_code("TRANSIT_CHECKIN_INTERVAL_BLOCK");
244        }
245
246        Ok(())
247    }
248
249    /// Retarget local holds that might wish to use our copy as
250    /// a target.  Useful if the copy is going from a non-holdable
251    /// to a holdable status and the hold targeter may not run
252    /// until, say, overnight.
253    fn checkin_retarget_holds(&mut self) -> EgResult<()> {
254        let copy = self.copy();
255
256        let retarget_mode = self
257            .options
258            .get("retarget_mode")
259            .map(|v| v.as_str().unwrap_or(""))
260            .unwrap_or("");
261
262        // A lot of scenarios can lead to avoiding hold fulfillment checks.
263        if !retarget_mode.contains("retarget")
264            || self.get_option_bool("revert_hold_fulfillment")
265            || self.capture_state() == "nocapture"
266            || self.is_precat_copy()
267            || copy["circ_lib"].int()? != self.circ_lib
268            || copy["deleted"].boolish()
269            || !copy["holdable"].boolish()
270            || !copy["status"]["holdable"].boolish()
271            || !copy["location"]["holdable"].boolish()
272        {
273            return Ok(());
274        }
275
276        // By default, we only care about in-process items.
277        if !retarget_mode.contains(".all") && self.copy_status() != C::COPY_STATUS_IN_PROCESS {
278            return Ok(());
279        }
280
281        let query = eg::hash! {target_copy: EgValue::from(self.copy_id)};
282        let parts = self.editor().search("acpm", query)?;
283        let parts = parts
284            .into_iter()
285            .map(|p| p.id().expect("ID Required"))
286            .collect::<HashSet<_>>();
287
288        let copy_id = self.copy_id;
289        let circ_lib = self.circ_lib;
290        let vol_id = self.copy()["call_number"].id()?;
291
292        let hold_data = holds::related_to_copy(
293            self.editor(),
294            copy_id,
295            Some(circ_lib),
296            None,
297            None,
298            Some(false), // already on holds shelf
299        )?;
300
301        // Since we're targeting a batch of holds, instead of a single hold,
302        // let the targeter manage the transaction.  Otherwise, we could
303        // target a large number of holds within a single transaction,
304        // which is no bueno.
305        let mut editor = self.editor().clone();
306        let mut hold_targeter = targeter::HoldTargeter::new(&mut editor);
307
308        for hold in hold_data.iter() {
309            let target = hold.target();
310            let hold_type: &str = hold.hold_type().into();
311
312            // Copy-level hold that points to a different copy.
313            if target != copy_id && (hold_type.eq("C") || hold_type.eq("R") || hold_type.eq("F")) {
314                continue;
315            }
316
317            // Volume-level hold for a different volume
318            if target != vol_id && hold_type.eq("V") {
319                continue;
320            }
321
322            if !parts.is_empty() {
323                // We have parts
324                if hold_type.eq("T") {
325                    continue;
326                } else if hold_type.eq("P") {
327                    // Skip part holds for parts that are related to our copy
328                    if !parts.contains(&target) {
329                        continue;
330                    }
331                }
332            } else if hold_type.eq("P") {
333                // We have no parts, skip part-type holds
334                continue;
335            }
336
337            let ctx = hold_targeter.target_hold(hold.id(), Some(copy_id))?;
338
339            if ctx.success() && ctx.found_copy() {
340                log::info!("checkin_retarget_holds() successfully targeted a hold");
341                break;
342            }
343        }
344
345        Ok(())
346    }
347
348    /// If have both an open circulation and an open transit,
349    /// cancel the transit.
350    fn cancel_transit_if_circ_exists(&mut self) -> EgResult<()> {
351        if self.circ.is_none() {
352            return Ok(());
353        }
354
355        if let Some(transit) = self.transit.as_ref() {
356            let transit_id = transit.id()?;
357            log::info!(
358                "{self} copy is both checked out and in transit.  Canceling transit {transit_id}"
359            );
360            transit::cancel_transit(self.editor(), transit_id, false)?;
361            self.transit = None;
362        }
363
364        Ok(())
365    }
366
367    /// Decides if we need to avoid certain LOST / LO processing for
368    /// transactions that have a zero balance.
369    fn set_dont_change_lost_zero(&mut self) -> EgResult<()> {
370        match self.copy_status() {
371            C::COPY_STATUS_LOST | C::COPY_STATUS_LOST_AND_PAID | C::COPY_STATUS_LONG_OVERDUE => {
372                // Found a copy me may want to work on,
373            }
374            _ => return Ok(()), // copy is not relevant
375        }
376
377        // LOST fine settings are controlled by the copy's circ lib,
378        // not the circulation's
379        let value = self.settings.get_value_at_org(
380            "circ.checkin.lost_zero_balance.do_not_change",
381            self.copy()["circ_lib"].int()?,
382        )?;
383
384        let mut dont_change = value.boolish();
385
386        if dont_change {
387            // Org setting says not to change.
388            // Make sure no balance is owed, or the setting is meaningless.
389
390            if let Some(circ) = self.circ.as_ref() {
391                let circ_id = circ["id"].clone();
392                if let Some(mbts) = self.editor().retrieve("mbts", circ_id)? {
393                    dont_change = mbts["balance_owed"].float()? == 0.0;
394                }
395            }
396        }
397
398        if dont_change {
399            self.set_option_true("dont_change_lost_zero");
400        }
401
402        Ok(())
403    }
404
405    /// Determines of our copy is eligible for floating.
406    fn set_can_float(&mut self) -> EgResult<()> {
407        let float_id = &self.copy()["floating"];
408
409        if float_id.is_null() {
410            // Copy is not configured to float
411            return Ok(());
412        }
413
414        let float_id = float_id.clone();
415
416        // Copy can float.  Can it float here?
417
418        let float_group = self
419            .editor()
420            .retrieve("cfg", float_id)?
421            .ok_or_else(|| self.editor().die_event())?;
422
423        let query = eg::hash! {
424            from: [
425                "evergreen.can_float",
426                float_group["id"].clone(),
427                self.copy()["circ_lib"].clone(),
428                self.circ_lib
429            ]
430        };
431
432        if let Some(resp) = self.editor().json_query(query)?.first() {
433            if resp["evergreen.can_float"].boolish() {
434                self.set_option_true("can_float");
435            }
436        }
437
438        Ok(())
439    }
440
441    /// Set an inventory date for our item if requested.
442    fn do_inventory_update(&mut self) -> EgResult<()> {
443        if !self.get_option_bool("do_inventory_update") {
444            return Ok(());
445        }
446
447        let ws_id = match self.editor().requestor_ws_id() {
448            Some(i) => i,
449            // Cannot perform inventory without a workstation.
450            None => return Ok(()),
451        };
452
453        if self.copy()["circ_lib"].int()? != self.circ_lib && !self.get_option_bool("can_float") {
454            // Item is not home and cannot float
455            return Ok(());
456        }
457
458        // Create a new copy inventory row.
459        let aci = eg::hash! {
460            inventory_date: "now",
461            inventory_workstation: ws_id,
462            copy: self.copy()["id"].clone(),
463        };
464
465        self.editor().create(aci)?;
466
467        Ok(())
468    }
469
470    /// True if our item is currently on the local holds shelf or sits
471    /// within a hold transit suppression group.
472    ///
473    /// Shelf-expired holds for our copy may also be cleared if requested.
474    fn check_is_on_holds_shelf(&mut self) -> EgResult<bool> {
475        if self.copy_status() != C::COPY_STATUS_ON_HOLDS_SHELF {
476            return Ok(false);
477        }
478
479        let copy_id = self.copy_id;
480
481        if self.get_option_bool("clear_expired") {
482            // Clear shelf-expired holds for this copy.
483            // TODO run in the same transaction once ported to Rust.
484
485            let params = vec![
486                EgValue::from(self.editor().authtoken()),
487                EgValue::from(self.circ_lib),
488                self.copy()["id"].clone(),
489            ];
490
491            self.editor().client_mut().send_recv_one(
492                "open-ils.circ",
493                "open-ils.circ.hold.clear_shelf.process",
494                params,
495            )?;
496        }
497
498        let hold = match holds::captured_hold_for_copy(self.editor(), copy_id)? {
499            Some(h) => h,
500            None => {
501                log::warn!("{self} Copy on holds shelf but there is no hold");
502                self.reshelve_copy(false)?;
503                return Ok(false);
504            }
505        };
506
507        let pickup_lib = hold["pickup_lib"].int()?;
508
509        log::info!("{self} we found a captured, un-fulfilled hold");
510
511        if pickup_lib != self.circ_lib && !self.get_option_bool("hold_as_transit") {
512            let suppress_here = self.settings.get_value("circ.transit.suppress_hold")?;
513
514            let suppress_here = suppress_here.string().unwrap_or_default();
515
516            let suppress_there = self
517                .settings
518                .get_value_at_org("circ.transit.suppress_hold", pickup_lib)?;
519
520            let suppress_there = suppress_there.string().unwrap_or_default();
521
522            if suppress_here == suppress_there && !suppress_here.is_empty() {
523                log::info!("{self} hold is within transit suppress group: {suppress_here}");
524                self.set_option_true("fake_hold_dest");
525                return Ok(true);
526            }
527        }
528
529        if pickup_lib == self.circ_lib && !self.get_option_bool("hold_as_transit") {
530            log::info!("{self} hold is for here");
531            return Ok(true);
532        }
533
534        log::info!("{self} hold is not for here");
535        self.options.insert(String::from("remote_hold"), hold);
536
537        Ok(false)
538    }
539
540    /// Sets our copy's status to the determined next-copy-status,
541    /// or to Reshelving, with a few potential execptions.
542    fn reshelve_copy(&mut self, force: bool) -> EgResult<()> {
543        let force = force || self.get_option_bool("force");
544
545        let status = self.copy_status();
546
547        let next_status = match self.options.get("next_copy_status") {
548            Some(s) => s.int()?,
549            None => C::COPY_STATUS_RESHELVING,
550        };
551
552        if force
553            || (status != C::COPY_STATUS_ON_HOLDS_SHELF
554                && status != C::COPY_STATUS_CATALOGING
555                && status != C::COPY_STATUS_IN_TRANSIT
556                && status != next_status)
557        {
558            self.update_copy(eg::hash! {status: EgValue::from(next_status)})?;
559        }
560
561        Ok(())
562    }
563
564    /// Returns claims-returned event if our circulation is claims returned.
565    fn check_claims_returned(&mut self) {
566        if let Some(circ) = self.circ.as_ref() {
567            if let Some(sf) = circ["stop_fines"].as_str() {
568                if sf == "CLAIMSRETURNED" {
569                    self.add_event_code("CIRC_CLAIMS_RETURNED");
570                }
571            }
572        }
573    }
574
575    /// Checks for an existing deposit payment and voids the deposit
576    /// if configured OR returns a deposit paid event.
577    fn check_circ_deposit(&mut self, void: bool) -> EgResult<()> {
578        let circ_id = match self.circ.as_ref() {
579            Some(c) => c["id"].clone(),
580            None => return Ok(()),
581        };
582
583        let query = eg::hash! {
584            btype: C::BTYPE_DEPOSIT,
585            voided: "f",
586            xact: circ_id,
587        };
588
589        let mut results = self.editor().search("mb", query)?;
590        let deposit = match results.pop() {
591            Some(d) => d,
592            None => return Ok(()),
593        };
594
595        if void {
596            // Caller suggests we void.  Verify settings allow it.
597            if self.settings.get_value("circ.void_item_deposit")?.boolish() {
598                let bill_id = deposit.id()?;
599                billing::void_bills(self.editor(), &[bill_id], Some("DEPOSIT ITEM RETURNED"))?;
600            }
601        } else {
602            let mut evt = EgEvent::new("ITEM_DEPOSIT_PAID");
603            evt.set_payload(deposit);
604            self.add_event(evt);
605        }
606
607        Ok(())
608    }
609
610    /// Checkin our open circulation and potentially kick off
611    /// lost/long-overdue item handling, among a few other smaller tasks.
612    fn checkin_handle_circ(&mut self) -> EgResult<()> {
613        let selfstr: String = self.to_string();
614
615        if self.get_option_bool("claims_never_checked_out") {
616            let xact_start = &self.circ.as_ref().unwrap()["xact_start"];
617            self.options
618                .insert("backdate".to_string(), xact_start.clone());
619        }
620
621        if self.options.contains_key("backdate") {
622            self.checkin_compile_backdate()?;
623        }
624
625        let copy_status = self.copy_status();
626        let copy_circ_lib = self.copy_circ_lib();
627
628        let req_id = self.requestor_id()?;
629        let req_ws_id = self.editor().requestor_ws_id();
630
631        let circ = self.circ.as_mut().unwrap();
632        let circ_id = circ.id()?;
633
634        circ["checkin_time"] = self
635            .options
636            .get("backdate")
637            .cloned()
638            .unwrap_or(EgValue::from("now"));
639
640        circ["checkin_scan_time"] = EgValue::from("now");
641        circ["checkin_staff"] = EgValue::from(req_id);
642        circ["checkin_lib"] = EgValue::from(self.circ_lib);
643        if let Some(id) = req_ws_id {
644            circ["checkin_workstation"] = EgValue::from(id);
645        }
646
647        log::info!(
648            "{selfstr} checking item in with checkin_time {}",
649            circ["checkin_time"]
650        );
651
652        match copy_status {
653            C::COPY_STATUS_LOST => self.checkin_handle_lost()?,
654            C::COPY_STATUS_LOST_AND_PAID => self.checkin_handle_lost()?,
655            C::COPY_STATUS_LONG_OVERDUE => self.checkin_handle_long_overdue()?,
656            C::COPY_STATUS_MISSING => {
657                if copy_circ_lib == self.circ_lib {
658                    self.reshelve_copy(true)?
659                } else {
660                    log::info!("{self} leaving copy in missing status on remote checkin");
661                }
662            }
663            _ => {
664                if !self.is_renewal() {
665                    // DB renew-permit function requires the renewed
666                    // copy to be in the checked-out status.
667                    self.reshelve_copy(true)?;
668                }
669            }
670        }
671
672        if self.get_option_bool("dont_change_lost_zero") {
673            // Caller has requested we leave well enough alone, i.e.
674            // if an item was lost and paid, it's not eligible to be
675            // re-opened for additional billing.
676            let circ = self.circ.as_ref().unwrap().clone();
677            self.editor().update(circ)?;
678        } else {
679            if self.get_option_bool("claims_never_checked_out") {
680                let circ = self.circ.as_mut().unwrap();
681                circ["stop_fines"] = EgValue::from("CLAIMSNEVERCHECKEDOUT");
682            } else if copy_status == C::COPY_STATUS_LOST {
683                // Note copy_status refers to the status of the copy
684                // before self.checkin_handle_lost() was called.
685
686                if self.get_option_bool("circ.lost.generate_overdue_on_checkin") {
687                    // As with Perl, this setting is based on the
688                    // runtime circ lib instead of the copy circ lib.
689
690                    // If this circ was LOST and we are configured to
691                    // generate overdue fines for lost items on checkin
692                    // (to fill the gap between mark lost time and when
693                    // the fines would have naturally stopped), then
694                    // clear stop_fines so the fine generator can work.
695                    let circ = self.circ.as_mut().unwrap();
696                    circ["stop_fines"].take();
697                }
698            }
699
700            let circ = self.circ.as_ref().unwrap().clone();
701            self.editor().update(circ)?;
702            self.handle_checkin_fines()?;
703        }
704
705        self.check_circ_deposit(true)?;
706
707        log::debug!("{selfstr} checking open transaction state");
708
709        // Set/clear stop_fines as needed.
710        billing::check_open_xact(self.editor(), circ_id)?;
711
712        // Get a post-save version of the circ to pick up any in-DB changes.
713        self.circ = self.editor().retrieve("circ", circ_id)?;
714
715        Ok(())
716    }
717
718    /// Collect params and call checkin_handle_lost_or_long_overdue()
719    fn checkin_handle_lost(&mut self) -> EgResult<()> {
720        log::info!("{self} processing LOST checkin...");
721
722        let billing_options = eg::hash! {
723            ous_void_item_cost: "circ.void_lost_on_checkin",
724            ous_void_proc_fee: "circ.void_lost_proc_fee_on_checkin",
725            ous_restore_overdue: "circ.restore_overdue_on_lost_return",
726            void_cost_btype: C::BTYPE_LOST_MATERIALS,
727            void_fee_btype: C::BTYPE_LOST_MATERIALS_PROCESSING_FEE,
728        };
729
730        self.options
731            .insert("lost_or_lo_billing_options".to_string(), billing_options);
732
733        self.checkin_handle_lost_or_long_overdue(
734            "circ.max_accept_return_of_lost",
735            "circ.lost_immediately_available",
736            None, // ous_use_last_activity not supported for LOST
737        )
738    }
739
740    /// Collect params and call checkin_handle_lost_or_long_overdue()
741    fn checkin_handle_long_overdue(&mut self) -> EgResult<()> {
742        let billing_options = eg::hash! {
743            is_longoverdue: true,
744            ous_void_item_cost: "circ.void_longoverdue_on_checkin",
745            ous_void_proc_fee: "circ.void_longoverdue_proc_fee_on_checkin",
746            ous_restore_overdue: "circ.restore_overdue_on_longoverdue_return",
747            void_cost_btype: C::BTYPE_LONG_OVERDUE_MATERIALS,
748            void_fee_btype: C::BTYPE_LONG_OVERDUE_MATERIALS_PROCESSING_FEE,
749        };
750
751        self.options
752            .insert("lost_or_lo_billing_options".to_string(), billing_options);
753
754        self.checkin_handle_lost_or_long_overdue(
755            "circ.max_accept_return_of_longoverdue",
756            "circ.longoverdue_immediately_available",
757            Some("circ.longoverdue.use_last_activity_date_on_return"),
758        )
759    }
760
761    /// Determines if/what additional LOST/LO handling is needed for
762    /// our circulation.
763    fn checkin_handle_lost_or_long_overdue(
764        &mut self,
765        ous_max_return: &str,
766        ous_immediately_available: &str,
767        ous_use_last_activity: Option<&str>,
768    ) -> EgResult<()> {
769        // Lost / Long-Overdue settings are based on the copy circ lib.
770        let copy_circ_lib = self.copy_circ_lib();
771        let max_return = self
772            .settings
773            .get_value_at_org(ous_max_return, copy_circ_lib)?
774            .clone(); // parallel
775        let mut too_late = false;
776
777        if let Some(max) = max_return.as_str() {
778            let last_activity = self.circ_last_billing_activity(ous_use_last_activity)?;
779            let last_activity = date::parse_datetime(&last_activity)?;
780
781            let last_chance = date::add_interval(last_activity, max)?;
782            too_late = last_chance > date::now();
783        }
784
785        if too_late {
786            log::info!(
787                "{self} check-in of lost/lo item exceeds max
788                return interval.  skipping fine/fee voiding, etc."
789            );
790        } else if self.get_option_bool("dont_change_lost_zero") {
791            log::info!(
792                "{self} check-in of lost/lo item having a balance
793                of zero, skipping fine/fee voiding and reinstatement."
794            );
795        } else {
796            log::info!(
797                "{self} check-in of lost/lo item is within the
798                max return interval (or no interval is defined).  Proceeding
799                with fine/fee voiding, etc."
800            );
801
802            self.set_option_true("needs_lost_bill_handling");
803        }
804
805        if self.circ_lib == copy_circ_lib {
806            // Lost/longoverdue item is home and processed.
807            // Treat like a normal checkin from this point on.
808            return self.reshelve_copy(true);
809        }
810
811        // Item is not home.  Does it go right back into rotation?
812        let available_now = self
813            .settings
814            .get_value_at_org(ous_immediately_available, copy_circ_lib)?
815            .boolish();
816
817        if available_now {
818            // Item status does not need to be retained.
819            // Put the item back into gen-pop.
820            self.reshelve_copy(true)
821        } else {
822            log::info!("{self}: leaving lost/longoverdue copy status in place on checkin");
823            Ok(())
824        }
825    }
826
827    /// Last billing activity is last payment time, last billing time, or the
828    /// circ due date.
829    ///
830    /// If the relevant "use last activity" org unit setting is
831    /// false/unset, then last billing activity is always the due date.
832    ///
833    /// Panics if self.circ is None.
834    fn circ_last_billing_activity(&mut self, maybe_setting: Option<&str>) -> EgResult<String> {
835        let copy_circ_lib = self.copy_circ_lib();
836        let circ = self.circ.as_ref().unwrap();
837        let circ_id = circ["id"].clone();
838
839        // to_string() early to avoid some mutable borrow issues
840        let due_date = circ["due_date"].as_str().unwrap().to_string();
841
842        let setting = match maybe_setting {
843            Some(s) => s,
844            None => return Ok(due_date),
845        };
846
847        let use_activity = self.settings.get_value_at_org(setting, copy_circ_lib)?;
848
849        if !use_activity.boolish() {
850            return Ok(due_date);
851        }
852
853        if let Some(mbts) = self.editor().retrieve("mbts", circ_id)? {
854            if let Some(last_payment) = mbts["last_payment_ts"].as_str() {
855                return Ok(last_payment.to_string());
856            }
857            if let Some(last_billing) = mbts["last_billing_ts"].as_str() {
858                return Ok(last_billing.to_string());
859            }
860        }
861
862        // No billing activity.  Fall back to due date.
863        Ok(due_date)
864    }
865
866    /// Compiles the exact backdate value.
867    ///
868    /// Assumes circ and options.backdate are set.
869    fn checkin_compile_backdate(&mut self) -> EgResult<()> {
870        let duedate = match self.circ.as_ref() {
871            Some(circ) => circ["due_date"]
872                .as_str()
873                .ok_or_else(|| format!("{self} circ has no due date?"))?,
874            None => return Ok(()),
875        };
876
877        let backdate = match self.options.get("backdate") {
878            Some(bd) => bd
879                .as_str()
880                .ok_or_else(|| format!("{self} bad backdate value: {bd}"))?,
881            None => return Ok(()),
882        };
883
884        // Set the backdate hour and minute based on the hour/minute
885        // of the original due date.
886        let orig_date = date::parse_datetime(duedate)?;
887        let mut new_date = date::parse_datetime(backdate)?;
888
889        new_date = new_date
890            .with_hour(orig_date.hour())
891            .ok_or_else(|| "Could not set backdate hours".to_string())?;
892
893        new_date = new_date
894            .with_minute(orig_date.minute())
895            .ok_or_else(|| "Could not set backdate minutes".to_string())?;
896
897        if new_date > date::now() {
898            log::info!("{self} ignoring future backdate: {new_date}");
899            self.options.remove("backdate");
900        } else {
901            self.options.insert(
902                "backdate".to_string(),
903                EgValue::from(date::to_iso(&new_date)),
904            );
905        }
906
907        Ok(())
908    }
909
910    /// Run our circ through fine generation and potentially perform
911    /// additional LOST/LO billing/voiding/etc steps.
912    fn handle_checkin_fines(&mut self) -> EgResult<()> {
913        let copy_circ_lib = self.copy_circ_lib();
914
915        if let Some(ops) = self.options.get("lost_or_lo_billing_options") {
916            if !self.get_option_bool("void_overdues") {
917                if let Some(setting) = ops["ous_restore_overdue"].as_str() {
918                    if self
919                        .settings
920                        .get_value_at_org(setting, copy_circ_lib)?
921                        .boolish()
922                    {
923                        self.checkin_handle_lost_or_lo_now_found_restore_od(false)?;
924                    }
925                }
926            }
927        }
928
929        let mut is_circ = false;
930        let xact_id = match self.circ.as_ref() {
931            Some(c) => {
932                is_circ = true;
933                c.id()?
934            }
935            None => match self.reservation.as_ref() {
936                Some(r) => r.id()?,
937                None => Err(format!(
938                    "{self} we have no transaction to generate fines for"
939                ))?,
940            },
941        };
942        if is_circ {
943            if self.circ.as_ref().unwrap()["stop_fines"].is_null() {
944                billing::generate_fines_for_circ(self.editor(), xact_id)?;
945
946                // Update our copy of the circ after billing changes,
947                // which may apply a stop_fines value.
948                self.circ = self.editor().retrieve("circ", xact_id)?;
949            }
950
951            self.set_circ_stop_fines()?;
952        } else {
953            billing::generate_fines_for_resv(self.editor(), xact_id)?;
954        }
955
956        if !self.get_option_bool("needs_lost_bill_handling") {
957            // No lost/lo billing work required.  All done.
958            return Ok(());
959        }
960
961        let ops = match self.options.get("lost_or_lo_billing_options") {
962            Some(o) => o,
963            None => Err("Cannot handle lost/lo billing without options".to_string())?,
964        };
965
966        // below was previously called checkin_handle_lost_or_lo_now_found()
967        let tag = if ops["is_longoverdue"].boolish() {
968            "LONGOVERDUE"
969        } else {
970            "LOST"
971        };
972        let note = format!("{tag} ITEM RETURNED");
973
974        let mut void_cost = 0.0;
975        if let Some(set) = ops["ous_void_item_cost"].as_str() {
976            if let Ok(c) = self.settings.get_value_at_org(set, copy_circ_lib)?.float() {
977                void_cost = c;
978            }
979        }
980
981        let mut void_proc_fee = 0.0;
982        if let Some(set) = ops["ous_void_proc_fee"].as_str() {
983            if let Ok(c) = self.settings.get_value_at_org(set, copy_circ_lib)?.float() {
984                void_proc_fee = c;
985            }
986        }
987
988        let void_cost_btype = ops["void_cost_btype"].as_i64().unwrap_or(0);
989        let void_fee_btype = ops["void_fee_btype"].as_i64().unwrap_or(0);
990
991        if void_cost > 0.0 {
992            if void_cost_btype == 0 {
993                log::warn!("Cannot zero {tag} circ without a billing type");
994                return Ok(());
995            }
996
997            billing::void_or_zero_bills_of_type(
998                self.editor(),
999                xact_id,
1000                copy_circ_lib,
1001                void_cost_btype,
1002                &note,
1003            )?;
1004        }
1005
1006        if void_proc_fee > 0.0 {
1007            if void_fee_btype == 0 {
1008                log::warn!("Cannot zero {tag} circ without a billing type");
1009                return Ok(());
1010            }
1011
1012            billing::void_or_zero_bills_of_type(
1013                self.editor(),
1014                xact_id,
1015                copy_circ_lib,
1016                void_fee_btype,
1017                &note,
1018            )?;
1019        }
1020
1021        Ok(())
1022    }
1023
1024    /// Apply a reasonable stop_fines / time value to our circ.
1025    ///
1026    /// Does nothing if the circ already has a stop_fines value.
1027    fn set_circ_stop_fines(&mut self) -> EgResult<()> {
1028        let circ = self.circ.as_ref().unwrap();
1029
1030        if !circ["stop_fines"].is_null() {
1031            return Ok(());
1032        }
1033
1034        // Set stop_fines and stop_fines_time on our open circulation.
1035        let stop_fines = if self.is_renewal() {
1036            "RENEW"
1037        } else if self.get_option_bool("claims_never_checked_out") {
1038            "CLAIMSNEVERCHECKEDOUT"
1039        } else {
1040            "CHECKIN"
1041        };
1042
1043        let stop_fines = EgValue::from(stop_fines);
1044
1045        let stop_fines_time = match self.options.get("backdate") {
1046            Some(bd) => bd.clone(),
1047            None => EgValue::from("now"),
1048        };
1049
1050        let mut circ = circ.clone();
1051
1052        let circ_id = circ["id"].clone();
1053
1054        circ["stop_fines"] = stop_fines;
1055        circ["stop_fines_time"] = stop_fines_time;
1056
1057        self.editor().update(circ)?;
1058
1059        // Update our copy to get in-DB changes.
1060        self.circ = self.editor().retrieve("circ", circ_id)?;
1061
1062        Ok(())
1063    }
1064
1065    /// Restore voided/adjusted overdue fines on lost/long-overdue return.
1066    fn checkin_handle_lost_or_lo_now_found_restore_od(
1067        &mut self,
1068        is_longoverdue: bool,
1069    ) -> EgResult<()> {
1070        let circ = self.circ.as_ref().unwrap();
1071        let circ_id = circ.id()?;
1072        let void_max = circ["max_fine"].float()?;
1073
1074        let query = eg::hash! {xact: circ_id, btype: C::BTYPE_OVERDUE_MATERIALS};
1075        let ops = eg::hash! {"order_by": {"mb": "billing_ts desc"}};
1076        let overdues = self.editor().search_with_ops("mb", query, ops)?;
1077
1078        if overdues.is_empty() {
1079            log::info!("{self} no overdues to reinstate on lost/lo checkin");
1080            return Ok(());
1081        }
1082
1083        let tag = if is_longoverdue {
1084            "LONGOVERRDUE"
1085        } else {
1086            "LOST"
1087        };
1088        log::info!("{self} re-instating {} pre-{tag} overdues", overdues.len());
1089
1090        let mut void_amount = 0.0;
1091
1092        let billing_ids: Vec<EgValue> = overdues.iter().map(|b| b["id"].clone()).collect();
1093        let voids = self
1094            .editor()
1095            .search("maa", eg::hash! {"billing": billing_ids})?;
1096
1097        if !voids.is_empty() {
1098            // Overdues adjusted via account adjustment
1099            for void in voids.iter() {
1100                void_amount += void["amount"].float()?;
1101            }
1102        } else {
1103            // Overdues voided the old-fashioned way, i.e. voided.
1104            for bill in overdues.iter() {
1105                if bill["voided"].boolish() {
1106                    void_amount += bill["amount"].float()?;
1107                }
1108            }
1109        }
1110
1111        if void_amount == 0.0 {
1112            log::info!("{self} voided overdues amounted to $0.00.  Nothing to restore");
1113            return Ok(());
1114        }
1115
1116        if void_amount > void_max {
1117            void_amount = void_max;
1118        }
1119
1120        // We have at least one overdue
1121        let first_od = overdues.first().unwrap();
1122        let last_od = overdues.last().unwrap();
1123
1124        let btype_label = first_od["billing_type"].as_str().unwrap(); // required field
1125        let period_start = first_od["period_start"].as_str();
1126        let period_end = last_od["period_end"].as_str();
1127
1128        let note = format!("System: {tag} RETURNED - OVERDUES REINSTATED");
1129
1130        billing::create_bill(
1131            self.editor(),
1132            void_amount,
1133            billing::BillingType {
1134                id: C::BTYPE_OVERDUE_MATERIALS,
1135                label: btype_label.to_string(),
1136            },
1137            circ_id,
1138            Some(&note),
1139            period_start,
1140            period_end,
1141        )?;
1142
1143        Ok(())
1144    }
1145
1146    /// Receive the transit or tell the caller it needs to go elsewhere.
1147    ///
1148    /// Assumes self.transit is set
1149    fn checkin_handle_transit(&mut self) -> EgResult<()> {
1150        log::info!("{self} attempting to receive transit");
1151
1152        let transit = self.transit.as_ref().unwrap();
1153        let transit_id = transit.id()?;
1154        let transit_dest = transit["dest"].int()?;
1155        let transit_copy_status = transit["copy_status"].int()?;
1156
1157        let for_hold = transit_copy_status == C::COPY_STATUS_ON_HOLDS_SHELF;
1158        let suppress_transit = self.should_suppress_transit(transit_dest, for_hold)?;
1159
1160        if for_hold && suppress_transit {
1161            self.set_option_true("fake_hold_dest");
1162        }
1163
1164        self.hold_transit = self.editor().retrieve("ahtc", transit_id)?;
1165
1166        if let Some(ht) = self.hold_transit.as_ref() {
1167            let hold_id = ht["hold"].clone();
1168            // A hold transit can have a null "hold" value if the linked
1169            // hold was anonymized while in transit.
1170            if !ht["hold"].is_null() {
1171                self.hold = self.editor().retrieve("ahr", hold_id)?;
1172            }
1173        }
1174
1175        let hold_as_transit = self.get_option_bool("hold_as_transit")
1176            && transit_copy_status == C::COPY_STATUS_ON_HOLDS_SHELF;
1177
1178        if !suppress_transit && (transit_dest != self.circ_lib || hold_as_transit) {
1179            // Item is in-transit to a different location OR
1180            // we are captured holds as transits and don't need another one.
1181
1182            log::info!(
1183                "{self}: Fowarding transit on copy which is destined
1184                for a different location. transit={transit_id} destination={transit_dest}"
1185            );
1186
1187            let mut evt = EgEvent::new("ROUTE_ITEM");
1188            evt.set_org(transit_dest);
1189
1190            return self.exit_ok_on_event(evt);
1191        }
1192
1193        // Receive the transit
1194        let mut transit = self.transit.take().unwrap();
1195        transit["dest_recv_time"] = EgValue::from("now");
1196        self.editor().update(transit)?;
1197
1198        // Refresh our copy of the transit.
1199        self.transit = self.editor().retrieve("atc", transit_id)?;
1200
1201        // Apply the destination copy status.
1202        self.update_copy(eg::hash! {"status": transit_copy_status})?;
1203
1204        if self.hold.is_some() {
1205            self.put_hold_on_shelf()?;
1206        } else {
1207            self.hold_transit = None;
1208            self.reshelve_copy(true)?;
1209            self.clear_option("fake_hold_dest");
1210        }
1211
1212        let mut payload = eg::hash! {
1213            transit: self.transit.as_ref().unwrap().clone()
1214        };
1215
1216        if let Some(ht) = self.hold_transit.as_ref() {
1217            payload["holdtransit"] = ht.clone();
1218        }
1219
1220        let mut evt = EgEvent::success();
1221        evt.set_payload(payload);
1222        evt.set_ad_hoc_value("ishold", EgValue::from(self.hold.is_some()));
1223
1224        self.add_event(evt);
1225
1226        Ok(())
1227    }
1228
1229    /// This handles standard hold transits as well as items
1230    /// that transited here w/o a hold transit yet are in
1231    /// fact captured for a hold.
1232    fn checkin_handle_received_hold(&mut self) -> EgResult<()> {
1233        if self.hold_transit.is_none() && self.copy_status() != C::COPY_STATUS_ON_HOLDS_SHELF {
1234            // No hold transit and not headed for the holds shelf.
1235            return Ok(());
1236        }
1237
1238        let copy_id = self.copy_id;
1239
1240        let mut alt_hold;
1241        let hold = match self.hold.as_mut() {
1242            Some(h) => h,
1243            None => match holds::captured_hold_for_copy(self.editor(), copy_id)? {
1244                Some(h) => {
1245                    alt_hold = Some(h);
1246                    alt_hold.as_mut().unwrap()
1247                }
1248                None => {
1249                    log::warn!("{self} item should be captured, but isn't, skipping");
1250                    return Ok(());
1251                }
1252            },
1253        };
1254
1255        if !hold["cancel_time"].is_null() || !hold["fulfillment_time"].is_null() {
1256            // Hold cancled or filled mid-transit
1257            self.reshelve_copy(false)?;
1258            self.clear_option("fake_hold_dest");
1259            return Ok(());
1260        }
1261
1262        if hold["hold_type"].as_str().unwrap() == "R" {
1263            // hold_type required
1264            self.update_copy(eg::hash! {status: C::COPY_STATUS_CATALOGING})?;
1265            self.clear_option("fake_hold_dest");
1266            // no further processing needed.
1267            self.set_option_true("noop");
1268
1269            let mut hold = self.hold.take().unwrap();
1270            let hold_id = hold.id()?;
1271            hold["fulfillment_time"] = EgValue::from("now");
1272            self.editor().update(hold)?;
1273
1274            self.hold = self.editor().retrieve("ahr", hold_id)?;
1275
1276            return Ok(());
1277        }
1278
1279        if self.get_option_bool("fake_hold_dest") {
1280            let hold = self.hold.as_mut().unwrap();
1281            // Perl code does not update the hold in the database
1282            // at this point.  Doing same.
1283            hold["pickup_lib"] = EgValue::from(self.circ_lib);
1284
1285            return Ok(());
1286        }
1287
1288        Ok(())
1289    }
1290
1291    /// Returns true if transits should be supressed between "here" and
1292    /// the provided destination.
1293    ///
1294    /// * `for_hold` - true if this would be a hold transit.
1295    fn should_suppress_transit(&mut self, destination: i64, for_hold: bool) -> EgResult<bool> {
1296        if destination == self.circ_lib {
1297            return Ok(false);
1298        }
1299
1300        if for_hold && self.get_option_bool("hold_as_transit") {
1301            return Ok(false);
1302        }
1303
1304        let setting = if for_hold {
1305            "circ.transit.suppress_hold"
1306        } else {
1307            "circ.transit.suppress_non_hold"
1308        };
1309
1310        // These value for these settings is opaque.  If a value is
1311        // set (i.e. not null), then we only care of they match.
1312        // Values are clone()ed to avoid parallel mutable borrows.
1313        let suppress_for_here = self.settings.get_value(setting)?.clone();
1314        if suppress_for_here.is_null() {
1315            return Ok(false);
1316        }
1317
1318        let suppress_for_dest = self
1319            .settings
1320            .get_value_at_org(setting, self.circ_lib)?
1321            .clone();
1322        if suppress_for_dest.is_null() {
1323            return Ok(false);
1324        }
1325
1326        // json::* knows if two EgValue's are the same.
1327        if suppress_for_here != suppress_for_dest {
1328            return Ok(false);
1329        }
1330
1331        Ok(true)
1332    }
1333
1334    /// Set hold shelf values and update the hold.
1335    fn put_hold_on_shelf(&mut self) -> EgResult<()> {
1336        let mut hold = self.hold.take().unwrap();
1337        let hold_id = hold.id()?;
1338
1339        hold["shelf_time"] = EgValue::from("now");
1340        hold["current_shelf_lib"] = EgValue::from(self.circ_lib);
1341
1342        if let Some(date) = holds::calc_hold_shelf_expire_time(self.editor(), &hold, None)? {
1343            hold["shelf_expire_time"] = EgValue::from(date);
1344        }
1345
1346        self.editor().update(hold)?;
1347        self.hold = self.editor().retrieve("ahr", hold_id)?;
1348
1349        Ok(())
1350    }
1351
1352    /// Attempt to capture our item for a hold or reservation.
1353    fn try_to_capture(&mut self) -> EgResult<bool> {
1354        if self.get_option_bool("remote_hold") {
1355            return Ok(false);
1356        }
1357
1358        if !self.is_booking_enabled() {
1359            return self.attempt_checkin_hold_capture();
1360        }
1361
1362        // XXX this would be notably faster if we didn't first check
1363        // for both hold and reservation capturability, i.e. if one
1364        // automatically took precedence.  As is, the capture logic,
1365        // which can be slow, has to run at minimum 3 times.
1366        let maybe_hold = self.hold_capture_is_possible()?;
1367        let maybe_resv = self.reservation_capture_is_possible()?;
1368
1369        if let Some(hold) = maybe_hold {
1370            if let Some(resv) = maybe_resv {
1371                // Hold and reservation == conflict.
1372                let mut evt = EgEvent::new("HOLD_RESERVATION_CONFLICT");
1373                evt.set_ad_hoc_value("hold", hold);
1374                evt.set_ad_hoc_value("reservation", resv);
1375                self.exit_err_on_event(evt)?;
1376                Ok(false)
1377            } else {
1378                // Hold but no reservation
1379                self.attempt_checkin_hold_capture()
1380            }
1381        } else if maybe_resv.is_some() {
1382            // Reservation, but no hold.
1383            self.attempt_checkin_reservation_capture()
1384        } else {
1385            // No nuthin
1386            Ok(false)
1387        }
1388    }
1389
1390    /// Try to capture our item for a hold.
1391    fn attempt_checkin_hold_capture(&mut self) -> EgResult<bool> {
1392        if self.capture_state() == "nocapture" {
1393            return Ok(false);
1394        }
1395
1396        let copy_id = self.copy_id;
1397
1398        let maybe_found = holds::find_nearest_permitted_hold(self.editor(), copy_id, false)?;
1399
1400        let (mut hold, retarget) = match maybe_found {
1401            Some(info) => info,
1402            None => {
1403                log::info!("{self} no permitted holds found for copy");
1404                return Ok(false);
1405            }
1406        };
1407
1408        if self.capture_state() != "capture" {
1409            // See if this item is in a hold-capture-verify location.
1410            if self.copy()["location"]["hold_verify"].boolish() {
1411                let mut evt = EgEvent::new("HOLD_CAPTURE_DELAYED");
1412                evt.set_ad_hoc_value("copy_location", self.copy()["location"].clone());
1413                self.exit_err_on_event(evt)?;
1414            }
1415        }
1416
1417        if !retarget.is_empty() {
1418            self.retarget_holds = Some(retarget);
1419        }
1420
1421        let pickup_lib = hold["pickup_lib"].int()?;
1422        let suppress_transit = self.should_suppress_transit(pickup_lib, true)?;
1423
1424        hold["hopeless_date"].take();
1425        hold["current_copy"] = EgValue::from(self.copy_id);
1426        hold["capture_time"] = EgValue::from("now");
1427
1428        // Clear some other potential cruft
1429        hold["fulfillment_time"].take();
1430        hold["fulfillment_staff"].take();
1431        hold["fulfillment_lib"].take();
1432        hold["expire_time"].take();
1433        hold["cancel_time"].take();
1434
1435        if suppress_transit
1436            || (pickup_lib == self.circ_lib && !self.get_option_bool("hold_as_transit"))
1437        {
1438            self.hold = Some(hold);
1439            // This updates and refreshes the hold.
1440            self.put_hold_on_shelf()?;
1441        } else {
1442            let hold_id = hold.id()?;
1443            self.editor().update(hold)?;
1444            self.hold = self.editor().retrieve("ahr", hold_id)?;
1445        }
1446
1447        Ok(true)
1448    }
1449
1450    fn attempt_checkin_reservation_capture(&mut self) -> EgResult<bool> {
1451        if self.capture_state() == "nocapture" {
1452            return Ok(false);
1453        }
1454
1455        let params = vec![
1456            EgValue::from(self.editor().authtoken()),
1457            self.copy()["barcode"].clone(),
1458            EgValue::from(true), // Avoid updating the copy.
1459        ];
1460
1461        let result = self.editor().client_mut().send_recv_one(
1462            "open-ils.booking",
1463            "open-ils.booking.resources.capture_for_reservation",
1464            params,
1465        )?;
1466
1467        let resp = result.ok_or_else(|| "Booking capture failed to return event".to_string())?;
1468
1469        let mut evt = EgEvent::parse(&resp)
1470            .ok_or_else(|| "Booking capture failed to return event".to_string())?;
1471
1472        if evt.textcode() == "RESERVATION_NOT_FOUND" {
1473            if let Some(cause) = evt.payload()["fail_cause"].as_str() {
1474                if cause == "not-transferable" {
1475                    log::warn!(
1476                        "{self} reservation capture attempted against non-transferable item"
1477                    );
1478                    self.add_event(evt);
1479                    return Ok(false);
1480                }
1481            }
1482        }
1483
1484        if !evt.is_success() {
1485            // Other non-success events are simply treated as non-captures.
1486            return Ok(false);
1487        }
1488
1489        log::info!("{self} booking capture succeeded");
1490
1491        if let Ok(stat) = evt.payload()["new_copy_status"].int() {
1492            self.update_copy(eg::hash! {"status": stat})?;
1493        }
1494
1495        let reservation = evt.payload_mut()["reservation"].take();
1496        if reservation.is_object() {
1497            self.reservation = Some(reservation);
1498        }
1499
1500        let transit = evt.payload_mut()["transit"].take();
1501        if transit.is_object() {
1502            let mut e = EgEvent::new("ROUTE_ITEM");
1503            e.set_org(transit["dest"].int()?);
1504            self.add_event(e);
1505        }
1506
1507        Ok(true)
1508    }
1509
1510    /// Returns a hold object if one is found which may be suitable
1511    /// for capturing our item.
1512    fn hold_capture_is_possible(&mut self) -> EgResult<Option<EgValue>> {
1513        if self.capture_state() == "nocapture" {
1514            return Ok(None);
1515        }
1516
1517        let copy_id = self.copy_id;
1518        let maybe_found =
1519            holds::find_nearest_permitted_hold(self.editor(), copy_id, true /* check only */)?;
1520
1521        let (hold, retarget) = match maybe_found {
1522            Some(info) => info,
1523            None => {
1524                log::info!("{self} no permitted holds found for copy");
1525                return Ok(None);
1526            }
1527        };
1528
1529        if !retarget.is_empty() {
1530            self.retarget_holds = Some(retarget);
1531        }
1532
1533        Ok(Some(hold))
1534    }
1535
1536    /// Returns a reservation object if one is found which may be suitable
1537    /// for capturing our item.
1538    fn reservation_capture_is_possible(&mut self) -> EgResult<Option<EgValue>> {
1539        if self.capture_state() == "nocapture" {
1540            return Ok(None);
1541        }
1542
1543        let params = vec![
1544            EgValue::from(self.editor().authtoken()),
1545            self.copy()["barcode"].clone(),
1546        ];
1547
1548        let result = self.editor().client_mut().send_recv_one(
1549            "open-ils.booking",
1550            "open-ils.booking.reservations.could_capture",
1551            params,
1552        )?;
1553
1554        if let Some(resp) = result {
1555            if let Some(evt) = EgEvent::parse(&resp) {
1556                self.exit_err_on_event(evt)?;
1557            } else {
1558                return Ok(Some(resp));
1559            }
1560        }
1561
1562        Ok(None)
1563    }
1564
1565    /// Determines if our item needs to transit somewhere else and
1566    /// builds the needed transit.
1567    fn try_to_transit(&mut self) -> EgResult<()> {
1568        let mut dest_lib = self.copy_circ_lib();
1569
1570        let mut has_remote_hold = false;
1571        if let Some(hold) = self.options.get("remote_hold") {
1572            has_remote_hold = true;
1573            if let Ok(pl) = hold["pickup_lib"].int() {
1574                dest_lib = pl;
1575            }
1576        }
1577
1578        let suppress_transit = self.should_suppress_transit(dest_lib, false)?;
1579        let hold_as_transit = self.get_option_bool("hold_as_transit");
1580
1581        if suppress_transit || (dest_lib == self.circ_lib && !(has_remote_hold && hold_as_transit))
1582        {
1583            // Copy is where it needs to be, either for hold or reshelving.
1584            return self.checkin_handle_precat();
1585        }
1586
1587        let can_float = self.get_option_bool("can_float");
1588        let manual_float =
1589            self.get_option_bool("manual_float") || self.copy()["floating"]["manual"].boolish();
1590
1591        if can_float && manual_float && !has_remote_hold {
1592            // Copy is floating -- make it stick here
1593            self.update_copy(eg::hash! {"circ_lib": self.circ_lib})?;
1594            return Ok(());
1595        }
1596
1597        // Copy needs to transit home
1598        self.checkin_build_copy_transit(dest_lib)?;
1599        let mut evt = EgEvent::new("ROUTE_ITEM");
1600        evt.set_org(dest_lib);
1601        self.add_event(evt);
1602
1603        Ok(())
1604    }
1605
1606    /// Set the item status to Cataloging and let the caller know
1607    /// it's a pre-cat item.
1608    fn checkin_handle_precat(&mut self) -> EgResult<()> {
1609        if !self.is_precat_copy() {
1610            return Ok(());
1611        }
1612
1613        if self.copy_status() != C::COPY_STATUS_CATALOGING {
1614            return Ok(());
1615        }
1616
1617        self.add_event_code("ITEM_NOT_CATALOGED");
1618
1619        self.update_copy(eg::hash! {"status": C::COPY_STATUS_CATALOGING})
1620            .map(|_| ())
1621    }
1622
1623    /// Create the actual transit object dn set our item as in-transit.
1624    fn checkin_build_copy_transit(&mut self, dest_lib: i64) -> EgResult<()> {
1625        let mut transit = eg::hash! {
1626            "source": self.circ_lib,
1627            "dest": dest_lib,
1628            "target_copy": self.copy_id,
1629            "source_send_time": "now",
1630            "copy_status": self.copy_status(),
1631        };
1632
1633        // If we are "transiting" an item to the holds shelf,
1634        // it's a hold transit.
1635        let maybe_remote_hold = self.options.get("remote_hold");
1636        let has_remote_hold = maybe_remote_hold.is_some();
1637
1638        if let Some(hold) = maybe_remote_hold.as_ref() {
1639            transit["hold"] = hold["id"].clone();
1640
1641            // Hold is transiting, clear any shelf-iness.
1642            if !hold["current_shelf_lib"].is_null() || !hold["shelf_time"].is_null() {
1643                let mut h = (*hold).clone();
1644                h["current_shelf_lib"].take();
1645                h["shelf_time"].take();
1646                self.editor().update(h)?;
1647            }
1648        }
1649
1650        log::info!("{self} transiting copy to {dest_lib}");
1651
1652        if has_remote_hold {
1653            let t = EgValue::create("ahtc", transit)?;
1654            let t = self.editor().create(t)?;
1655            self.hold_transit = self.editor().retrieve("ahtc", t["id"].clone())?;
1656        } else {
1657            let t = EgValue::create("atc", transit)?;
1658            let t = self.editor().create(t)?;
1659            self.transit = self.editor().retrieve("ahtc", t["id"].clone())?;
1660        }
1661
1662        self.update_copy(eg::hash! {"status": C::COPY_STATUS_IN_TRANSIT})?;
1663        Ok(())
1664    }
1665
1666    /// Maybe void overdues and verify the transaction has the correct
1667    /// open/closed state.
1668    fn finish_fines_and_voiding(&mut self) -> EgResult<()> {
1669        let void_overdues = self.get_option_bool("void_overdues");
1670        let mut backdate_maybe = match self.options.get("backate") {
1671            Some(bd) => bd.as_str().map(|d| d.to_string()),
1672            None => None,
1673        };
1674
1675        let circ_id = match self.circ.as_ref() {
1676            Some(c) => c.id()?,
1677            None => return Ok(()),
1678        };
1679
1680        if !void_overdues && backdate_maybe.is_none() {
1681            return Ok(());
1682        }
1683
1684        let mut note_maybe = None;
1685
1686        if void_overdues {
1687            note_maybe = Some("System: Amnesty Checkin");
1688            backdate_maybe = None;
1689        }
1690
1691        billing::void_or_zero_overdues(
1692            self.editor(),
1693            circ_id,
1694            backdate_maybe.as_deref(),
1695            note_maybe,
1696            false,
1697            false,
1698        )?;
1699
1700        billing::check_open_xact(self.editor(), circ_id)
1701    }
1702
1703    /// This assumes the caller is finished with all processing and makes
1704    /// changes to local copies if data (e.g. setting copy = None for editing).
1705    fn flesh_checkin_events(&mut self) -> EgResult<()> {
1706        let mut copy = self.copy.take().unwrap().take(); // assumes copy
1707        let copy_id = self.copy_id;
1708        let record_id = copy["call_number"]["record"].int()?;
1709
1710        // Grab the volume before it's de-fleshed.
1711        let volume = copy["call_number"].take();
1712        copy["call_number"] = volume["id"].clone();
1713
1714        // De-flesh the copy
1715        copy.deflesh()?;
1716
1717        let mut payload = eg::hash! {
1718            "copy": copy,
1719            "volume": volume,
1720        };
1721
1722        if !self.is_precat_copy() {
1723            if let Some(rec) = self.editor().retrieve("rmsr", record_id)? {
1724                payload["title"] = rec;
1725            }
1726        }
1727
1728        if let Some(mut hold) = self.hold.take() {
1729            if hold["cancel_time"].is_null() {
1730                hold["notes"] = EgValue::from(
1731                    self.editor()
1732                        .search("ahrn", eg::hash! {hold: hold["id"].clone()})?,
1733                );
1734                payload["hold"] = hold;
1735            }
1736        }
1737
1738        if let Some(circ) = self.circ.as_ref() {
1739            let flesh = eg::hash! {
1740                "flesh": 1,
1741                "flesh_fields": {
1742                    "circ": ["billable_transaction"],
1743                    "mbt": ["summary"]
1744                }
1745            };
1746
1747            let circ_id = circ["id"].clone();
1748
1749            if let Some(fcirc) = self.editor().retrieve_with_ops("circ", circ_id, flesh)? {
1750                payload["circ"] = fcirc;
1751            }
1752        }
1753
1754        if let Some(patron) = self.patron.as_ref() {
1755            let flesh = eg::hash! {
1756                "flesh": 1,
1757                "flesh_fields": {
1758                    "au": ["card", "billing_address", "mailing_address"]
1759                }
1760            };
1761
1762            let patron_id = patron["id"].clone();
1763
1764            if let Some(fpatron) = self.editor().retrieve_with_ops("au", patron_id, flesh)? {
1765                payload["patron"] = fpatron;
1766            }
1767        }
1768
1769        if let Some(reservation) = self.reservation.take() {
1770            payload["reservation"] = reservation;
1771        }
1772
1773        if let Some(transit) = self.hold_transit.take().or(self.transit.take()) {
1774            payload["transit"] = transit;
1775        }
1776
1777        let query = eg::hash! {"copy": copy_id};
1778        let flesh = eg::hash! {
1779            "flesh": 1,
1780            "flesh_fields": {
1781                "alci": ["inventory_workstation"]
1782            }
1783        };
1784
1785        if let Some(inventory) = self.editor().search_with_ops("alci", query, flesh)?.pop() {
1786            payload["copy"]["latest_inventory"] = inventory;
1787        }
1788
1789        // Should never happen, but to be safe:
1790        if self.events.is_empty() {
1791            self.events.push(EgEvent::new("NO_CHANGE"));
1792        }
1793
1794        // Clone the payload into any additional events for full coverage.
1795        for (idx, evt) in self.events.iter_mut().enumerate() {
1796            if idx > 0 {
1797                evt.set_payload(payload.clone());
1798            }
1799        }
1800
1801        // Capture the uncloned payload into the first event (which will
1802        // always be present).
1803        self.events[0].set_payload(payload);
1804
1805        Ok(())
1806    }
1807
1808    /// Returns true if a hold revert was requested but it does
1809    /// not make sense with the data we have.
1810    pub fn hold_revert_sanity_checks(&mut self) -> EgResult<()> {
1811        if !self.get_option_bool("revert_hold_fulfillment") {
1812            return Ok(());
1813        }
1814
1815        if self.circ.is_some()
1816            && self.copy.is_some()
1817            && self.copy_status() == C::COPY_STATUS_CHECKED_OUT
1818            && self.patron.is_some()
1819            && !self.is_renewal()
1820        {
1821            return Ok(());
1822        }
1823
1824        log::warn!("{self} hold-revert requested but makes no sense");
1825
1826        // Return an inocuous event response to avoid spooking
1827        // SIP clients -- also, it's true.
1828        Err(EgEvent::new("NO_CHANGE").into())
1829    }
1830
1831    /// Returns true if a hold fulfillment was reverted.
1832    fn revert_hold_fulfillment(&mut self) -> EgResult<bool> {
1833        if !self.get_option_bool("revert_hold_fulfillment") {
1834            return Ok(false);
1835        }
1836
1837        let query = eg::hash! {
1838            "usr": self.patron.as_ref().unwrap()["id"].clone(),
1839            "cancel_time": EgValue::Null,
1840            "fulfillment_time": {"!=": EgValue::Null},
1841            "current_copy": self.copy()["id"].clone(),
1842        };
1843
1844        let ops = eg::hash! {
1845            "order_by": {
1846                "ahr": "fulfillment_time desc"
1847            },
1848            "limit": 1
1849        };
1850
1851        let mut hold = match self.editor().search_with_ops("ahr", query, ops)?.pop() {
1852            Some(h) => h,
1853            None => return Ok(false),
1854        };
1855
1856        // The hold fulfillment time will match the xact_start time of
1857        // its companion circulation.
1858        let xact_date =
1859            date::parse_datetime(self.circ.as_ref().unwrap()["xact_start"].as_str().unwrap())?;
1860
1861        let ff_date = date::parse_datetime(
1862            self.hold.as_ref().unwrap()["fulfillment_time"]
1863                .as_str()
1864                .unwrap(),
1865        )?;
1866
1867        // In some cases the date stored in PG contains milliseconds and
1868        // in other cases not. To make an accurate comparison, truncate
1869        // to seconds.
1870        if xact_date.timestamp() != ff_date.timestamp() {
1871            return Ok(false);
1872        }
1873
1874        log::info!("{self} undoing fulfillment for hold {}", hold["id"]);
1875
1876        hold["fulfillment_time"].take();
1877        hold["fulfillment_staff"].take();
1878        hold["fulfillment_lib"].take();
1879
1880        self.editor().update(hold)?;
1881
1882        self.update_copy(eg::hash! {"status": C::COPY_STATUS_ON_HOLDS_SHELF})?;
1883
1884        Ok(true)
1885    }
1886}