1use crate as eg;
2use eg::common::bib;
3use eg::common::billing;
4use eg::common::circulator::{CircOp, CircPolicy, Circulator, LEGACY_CIRC_EVENT_MAP};
5use eg::common::holds;
6use eg::common::noncat;
7use eg::common::org;
8use eg::common::penalty;
9use eg::constants as C;
10use eg::date;
11use eg::event::EgEvent;
12use eg::result::EgResult;
13use eg::EgValue;
14use std::time::Duration;
15
16impl Circulator<'_> {
18 pub fn checkout(&mut self) -> EgResult<()> {
24 if self.circ_op == CircOp::Unset {
25 self.circ_op = CircOp::Checkout;
26 }
27 self.init()?;
28
29 log::info!("{self} starting checkout");
30
31 if self.patron.is_none() {
32 return self.exit_err_on_event_code("ACTOR_USER_NOT_FOUND");
33 }
34
35 self.base_checkout_perms()?;
36
37 self.set_circ_policy()?;
38 self.inspect_policy_failures()?;
39 self.check_copy_alerts()?;
40 self.try_override_events()?;
41
42 if self.is_inspect() {
43 return Ok(());
44 }
45
46 if self.is_noncat {
47 return self.checkout_noncat();
48 }
49
50 if self.precat_requested() {
51 self.create_precat_copy()?;
52 } else if self.is_precat_copy() && !self.is_renewal() {
53 self.exit_err_on_event_code("ITEM_NOT_CATALOGED")?;
54 }
55
56 self.basic_copy_checks()?;
57 self.set_item_deposit_events()?;
58 self.check_captured_hold()?;
59 self.check_copy_status()?;
60 self.handle_claims_returned()?;
61 self.check_for_open_circ()?;
62
63 self.try_override_events()?;
64
65 self.build_checkout_circ()?;
68 self.apply_due_date()?;
69 self.save_checkout_circ()?;
70 self.apply_limit_groups()?;
71
72 self.apply_deposit_fee()?;
73 self.handle_checkout_holds()?;
74
75 penalty::calculate_penalties(self.editor, self.patron_id, self.circ_lib, None)?;
76
77 self.build_checkout_response()
78 }
79
80 fn base_checkout_perms(&mut self) -> EgResult<()> {
82 let cl = self.circ_lib;
83
84 if !self.is_renewal() && !self.editor().allowed_at("COPY_CHECKOUT", cl)? {
85 return Err(self.editor().die_event());
86 }
87
88 if self.patron_id != self.editor().requestor_id()? {
89 if !self.editor().allowed_at("VIEW_PERMIT_CHECKOUT", cl)? {
91 return Err(self.editor().die_event());
92 }
93 }
94
95 Ok(())
96 }
97
98 fn checkout_noncat(&mut self) -> EgResult<()> {
99 let noncat_type = match self.options.get("noncat_type") {
100 Some(v) => v,
101 None => return Err("noncat_type required".into()),
102 };
103
104 let circ_lib = match self.options.get("noncat_circ_lib") {
105 Some(cl) => cl.int()?,
106 None => self.circ_lib,
107 };
108
109 let count = match self.options.get("noncat_count") {
110 Some(c) => c.int()?,
111 None => 1,
112 };
113
114 let mut checkout_time = None;
115 if let Some(ct) = self.options.get("checkout_time") {
116 if let Some(ct2) = ct.as_str() {
117 checkout_time = Some(ct2.to_string());
118 }
119 }
120
121 let patron_id = self.patron_id;
122 let noncat_type = noncat_type.int()?;
123
124 let mut circs = noncat::checkout(
125 self.editor(),
126 patron_id,
127 noncat_type,
128 circ_lib,
129 count,
130 checkout_time.as_deref(),
131 )?;
132
133 let mut evt = EgEvent::success();
134 if let Some(c) = circs.pop() {
135 evt.set_payload(eg::hash! {"noncat_circ": c});
137 }
138 self.add_event(evt);
139
140 Ok(())
141 }
142
143 fn create_precat_copy(&mut self) -> EgResult<()> {
144 if !self.is_renewal() && !self.editor().allowed("CREATE_PRECAT")? {
145 return Err(self.editor().die_event());
146 }
147
148 if self.copy.is_some() {
151 return self.update_existing_precat();
152 }
153
154 let reqr_id = self.requestor_id()?;
155
156 let dummy_title = self
157 .options
158 .get("dummy_title")
159 .map(|dt| dt.as_str())
160 .unwrap_or(Some(""))
161 .unwrap();
162
163 let dummy_author = self
164 .options
165 .get("dummy_author")
166 .map(|dt| dt.as_str())
167 .unwrap_or(Some(""))
168 .unwrap();
169
170 let dummy_isbn = self
171 .options
172 .get("dummy_isbn")
173 .map(|dt| dt.as_str())
174 .unwrap_or(Some(""))
175 .unwrap();
176
177 let circ_modifier = self
178 .options
179 .get("circ_modifier")
180 .map(|m| m.as_str())
181 .unwrap_or(Some(""))
182 .unwrap();
183
184 let copy_barcode = self.copy_barcode.as_deref().unwrap();
186
187 log::info!("{self} creating new pre-cat copy {copy_barcode}");
188
189 let copy = eg::hash! {
190 "circ_lib": self.circ_lib,
191 "creator": reqr_id,
192 "editor": reqr_id,
193 "barcode": copy_barcode,
194 "dummy_title": dummy_title,
195 "dummy_author": dummy_author,
196 "dummy_isbn": dummy_isbn,
197 "circ_modifier": circ_modifier,
198 "call_number": C::PRECAT_CALL_NUMBER,
199 "loan_duration": C::PRECAT_COPY_LOAN_DURATION,
200 "fine_level": C::PRECAT_COPY_FINE_LEVEL,
201 };
202
203 let mut copy = EgValue::create("acp", copy)?;
204
205 let pclib = self
206 .settings
207 .get_value("circ.pre_cat_copy_circ_lib")?
208 .clone();
209
210 if let Some(sn) = pclib.as_str() {
211 let o = org::by_shortname(self.editor(), sn)?;
212 copy["circ_lib"] = o["id"].clone();
213 }
214
215 let copy = self.editor().create(copy)?;
216
217 self.copy_id = copy.id()?;
218
219 self.load_copy()?;
221
222 Ok(())
223 }
224
225 fn update_existing_precat(&mut self) -> EgResult<()> {
226 let copy = self.copy.as_ref().unwrap(); log::info!("{self} modifying existing pre-cat copy {}", copy["id"]);
229
230 let dummy_title = self
231 .options
232 .get("dummy_title")
233 .map(|dt| dt.as_str())
234 .unwrap_or(copy["dummy_title"].as_str())
235 .unwrap_or("");
236
237 let dummy_author = self
238 .options
239 .get("dummy_author")
240 .map(|dt| dt.as_str())
241 .unwrap_or(copy["dummy_author"].as_str())
242 .unwrap_or("");
243
244 let dummy_isbn = self
245 .options
246 .get("dummy_isbn")
247 .map(|dt| dt.as_str())
248 .unwrap_or(copy["dummy_isbn"].as_str())
249 .unwrap_or("");
250
251 let circ_modifier = self
252 .options
253 .get("circ_modifier")
254 .map(|m| m.as_str())
255 .unwrap_or(copy["circ_modifier"].as_str());
256
257 self.update_copy(eg::hash! {
258 "editor": self.requestor_id()?,
259 "edit_date": "now",
260 "dummy_title": dummy_title,
261 "dummy_author": dummy_author,
262 "dummy_isbn": dummy_isbn,
263 "circ_modifier": circ_modifier,
264 })?;
265
266 Ok(())
267 }
268
269 fn set_item_deposit_events(&mut self) -> EgResult<()> {
270 if self.is_deposit() && !self.is_deposit_exempt()? {
271 let mut evt = EgEvent::new("ITEM_DEPOSIT_REQUIRED");
272 evt.set_payload(self.copy().clone());
273 self.add_event(evt)
274 }
275
276 if self.is_rental() && !self.is_rental_exempt()? {
277 let mut evt = EgEvent::new("ITEM_RENTAL_FEE_REQUIRED");
278 evt.set_payload(self.copy().clone());
279 self.add_event(evt)
280 }
281
282 Ok(())
283 }
284
285 fn check_captured_hold(&mut self) -> EgResult<()> {
286 if self.copy()["status"].id()? != C::COPY_STATUS_ON_HOLDS_SHELF {
287 return Ok(());
288 }
289
290 let query = eg::hash! {
291 "current_copy": self.copy_id,
292 "capture_time": {"!=": eg::NULL },
293 "cancel_time": eg::NULL,
294 "fulfillment_time": eg::NULL
295 };
296
297 let flesh = eg::hash! {
298 "limit": 1,
299 "flesh": 1,
300 "flesh_fields": {"ahr": ["usr"]}
301 };
302
303 let hold = match self.editor().search_with_ops("ahr", query, flesh)?.pop() {
304 Some(h) => h,
305 None => return Ok(()),
306 };
307
308 if hold["usr"].id()? == self.patron_id {
309 self.checkout_is_for_hold = Some(hold);
310 return Ok(());
311 }
312
313 log::info!("{self} item is on holds shelf for another patron");
314
315 let fname = hold["usr"]["first_given_name"].string()?;
318 let lname = hold["usr"]["family_name"].string()?;
319 let pid = hold["usr"]["id"].int()?;
320 let hid = hold["id"].int()?;
321
322 let payload = eg::hash! {
323 "patron_name": format!("{fname} {lname}"),
324 "patron_id": pid,
325 "hold_id": hid,
326 };
327
328 let mut evt = EgEvent::new("ITEM_ON_HOLDS_SHELF");
329 evt.set_payload(payload);
330 self.add_event(evt);
331
332 self.hold_found_for_alt_patron = Some(hold);
333
334 Ok(())
335 }
336
337 fn check_copy_status(&mut self) -> EgResult<()> {
338 if let Some(copy) = self.copy.as_ref() {
339 if let Some(id) = copy["status"]["id"].as_i64() {
340 if id == C::COPY_STATUS_IN_TRANSIT {
341 self.exit_err_on_event_code("COPY_IN_TRANSIT")?;
342 }
343 }
344 }
345 Ok(())
346 }
347
348 fn handle_claims_returned(&mut self) -> EgResult<()> {
352 let query = eg::hash! {
353 "target_copy": self.copy_id,
354 "stop_fines": "CLAIMSRETURNED",
355 "checkin_time": eg::NULL,
356 };
357
358 let mut circ = match self.editor().search("circ", query)?.pop() {
359 Some(c) => c,
360 None => return Ok(()),
361 };
362
363 if !self.can_override_event("CIRC_CLAIMS_RETURNED") {
364 return self.exit_err_on_event_code("CIRC_CLAIMS_RETURNED");
365 }
366
367 circ["checkin_time"] = EgValue::from("now");
368 circ["checkin_scan_time"] = EgValue::from("now");
369 circ["checkin_lib"] = EgValue::from(self.circ_lib);
370 circ["checkin_staff"] = EgValue::from(self.requestor_id()?);
371
372 if let Some(id) = self.editor().requestor_ws_id() {
373 circ["checkin_workstation"] = EgValue::from(id);
374 }
375
376 self.editor().update(circ).map(|_| ())
377 }
378
379 fn check_for_open_circ(&mut self) -> EgResult<()> {
380 if self.is_renewal() {
381 return Ok(());
382 }
383
384 let query = eg::hash! {
385 "target_copy": self.copy_id,
386 "checkin_time": eg::NULL,
387 };
388
389 let circ = match self.editor().search("circ", query)?.pop() {
390 Some(c) => c,
391 None => return Ok(()),
392 };
393
394 let mut payload = eg::hash! {"copy": self.copy().clone()};
395
396 if self.patron_id == circ["usr"].int()? {
397 payload["old_circ"] = circ.clone();
398
399 if let Some(intvl) = self
405 .settings
406 .get_value("circ.checkout_auto_renew_age")?
407 .as_str()
408 {
409 let interval = date::interval_to_seconds(intvl)?;
410 let xact_start = date::parse_datetime(circ["xact_start"].as_str().unwrap())?;
411
412 let cutoff = xact_start + Duration::from_secs(interval as u64);
413
414 if date::now() > cutoff {
415 payload["auto_renew"] = EgValue::from(1);
416 }
417 }
418 }
419
420 let mut evt = EgEvent::new("OPEN_CIRCULATION_EXISTS");
421 evt.set_payload(payload);
422
423 self.exit_err_on_event(evt)
424 }
425
426 fn set_circ_policy(&mut self) -> EgResult<()> {
431 let func = if self.is_renewal() {
432 "action.item_user_renew_test"
433 } else {
434 "action.item_user_circ_test"
435 };
436
437 let copy_id = if self.copy.is_none()
440 || self.is_noncat
441 || (self.precat_requested() && !self.is_override && !self.is_renewal())
442 {
443 eg::NULL
444 } else {
445 EgValue::from(self.copy_id)
446 };
447
448 let query = eg::hash! {
449 "from": [
450 func,
451 self.circ_lib,
452 copy_id,
453 self.patron_id
454 ]
455 };
456
457 let results = self.editor().json_query(query)?;
458
459 log::debug!("{self} {func} returned: {:?}", results);
460
461 if results.is_empty() {
462 return self.exit_err_on_event_code("NO_POLICY_MATCHPOINT");
463 };
464
465 let policy = &results[0];
469
470 self.circ_test_success = policy["success"].boolish();
471
472 if self.circ_test_success && policy["duration_rule"].is_null() {
473 self.circ_policy_unlimited = true;
476 return Ok(());
477 }
478
479 if policy["matchpoint"].is_null() {
480 self.circ_policy_results = Some(results);
481 return Ok(());
482 }
483
484 let err = || format!("Incomplete circ policy: {}", policy);
486
487 let limit_groups = if policy["limit_groups"].is_array() {
488 Some(policy["limit_groups"].clone())
489 } else {
490 None
491 };
492
493 let mut duration_rule = self
494 .editor()
495 .retrieve("crcd", policy["duration_rule"].clone())?
496 .ok_or_else(err)?;
497
498 let mut recurring_fine_rule = self
499 .editor()
500 .retrieve("crrf", policy["recurring_fine_rule"].clone())?
501 .ok_or_else(err)?;
502
503 let max_fine_rule = self
504 .editor()
505 .retrieve("crmf", policy["max_fine_rule"].clone())?
506 .ok_or_else(err)?;
507
508 let hard_due_date = self
510 .editor()
511 .retrieve("chdd", policy["hard_due_date"].clone())?;
512
513 if let Ok(n) = policy["renewals"].int() {
514 duration_rule["max_renewals"] = EgValue::from(n);
515 }
516
517 if let Some(s) = policy["grace_period"].as_str() {
518 recurring_fine_rule["grace_period"] = EgValue::from(s);
519 }
520
521 let max_fine = self.calc_max_fine(&max_fine_rule)?;
522 let copy = self.copy();
523
524 let copy_duration = copy["loan_duration"].int()?;
525 let copy_fine_level = copy["fine_level"].int()?;
526
527 let duration = match copy_duration {
528 C::CIRC_DURATION_SHORT => duration_rule["shrt"].string()?,
529 C::CIRC_DURATION_EXTENDED => duration_rule["extended"].string()?,
530 _ => duration_rule["normal"].string()?,
531 };
532
533 let recurring_fine = match copy_fine_level {
534 C::CIRC_FINE_LEVEL_LOW => recurring_fine_rule["low"].float()?,
535 C::CIRC_FINE_LEVEL_HIGH => recurring_fine_rule["high"].float()?,
536 _ => recurring_fine_rule["normal"].float()?,
537 };
538
539 let matchpoint = policy["matchpoint"].clone();
540
541 let rules = CircPolicy {
542 matchpoint,
543 duration,
544 recurring_fine,
545 max_fine,
546 duration_rule,
547 recurring_fine_rule,
548 max_fine_rule,
549 hard_due_date,
550 limit_groups,
551 };
552
553 self.circ_policy_rules = Some(rules);
554 self.circ_policy_results = Some(results);
555
556 Ok(())
557 }
558
559 fn inspect_policy_failures(&mut self) -> EgResult<()> {
561 if self.circ_test_success {
562 return Ok(());
563 }
564
565 let mut policy_results = match self.circ_policy_results.take() {
566 Some(p) => p,
567 None => Err("Non-success circ policy has no policy data".to_string())?,
568 };
569
570 if self.is_noncat || self.precat_requested() {
571 policy_results.retain(|r| {
575 if let Some(fp) = r["fail_part"].as_str() {
576 return fp != "no_item";
577 }
578 true
579 });
580 }
581
582 if self.checkout_is_for_hold.is_some() {
583 let penalty_codes: Vec<&str> = policy_results
587 .iter()
588 .filter(|r| r["fail_part"].is_string())
589 .map(|r| r.as_str().unwrap())
590 .collect();
591
592 let query = eg::hash! {
593 "name": penalty_codes,
594 "block_list": {"like": "%CIRC%"}
595 };
596
597 let block_pens = self.editor().search("csp", query)?;
598 let block_pen_names: Vec<&str> = block_pens
599 .iter()
600 .map(|p| p["name"].as_str().unwrap())
601 .collect();
602
603 let mut keepers = Vec::new();
604
605 for pr in policy_results.drain(..) {
606 let pr_name = pr["fail_part"].as_str().unwrap_or("");
607 if !block_pen_names.contains(&pr_name) {
608 keepers.push(pr);
609 }
610 }
611
612 policy_results = keepers;
613 }
614
615 for pr in policy_results.iter() {
618 let fail_part = match pr["fail_part"].as_str() {
619 Some(fp) => fp,
620 None => continue,
621 };
622
623 let evt_code = LEGACY_CIRC_EVENT_MAP
626 .iter()
627 .filter(|(fp, _)| fp == &fail_part)
628 .map(|(_, code)| code)
629 .next()
630 .unwrap_or(&fail_part);
631
632 self.add_event_code(evt_code);
633 }
634
635 self.circ_policy_results = Some(policy_results);
636
637 Ok(())
638 }
639
640 fn calc_max_fine(&mut self, max_fine_rule: &EgValue) -> EgResult<f64> {
641 let rule_amount = max_fine_rule["amount"].float()?;
642
643 let copy_id = self.copy_id;
644
645 if max_fine_rule["is_percent"].boolish() {
646 let copy_price = billing::get_copy_price(self.editor(), copy_id)?;
647 return Ok((copy_price * rule_amount) / 100.0);
648 }
649
650 if self
651 .settings
652 .get_value("circ.max_fine.cap_at_price")?
653 .boolish()
654 {
655 let copy_price = billing::get_copy_price(self.editor(), copy_id)?;
656 let amount = if rule_amount > copy_price {
657 copy_price
658 } else {
659 rule_amount
660 };
661
662 return Ok(amount);
663 }
664
665 Ok(rule_amount)
666 }
667
668 fn build_checkout_circ(&mut self) -> EgResult<()> {
669 let mut circ = eg::hash! {
670 "target_copy": self.copy_id,
671 "usr": self.patron_id,
672 "circ_lib": self.circ_lib,
673 "circ_staff": self.requestor_id()?,
674 };
675
676 if let Some(ws) = self.editor().requestor_ws_id() {
677 circ["workstation"] = EgValue::from(ws);
678 };
679
680 if let Some(ct) = self.options.get("checkout_time") {
681 circ["xact_start"] = ct.clone();
682 }
683
684 if let Some(id) = self.parent_circ {
685 circ["parent_circ"] = EgValue::from(id);
686 }
687
688 if self.is_renewal() {
689 if self
690 .options
691 .get("opac_renewal")
692 .unwrap_or(&eg::NULL)
693 .boolish()
694 {
695 circ["opac_renewal"] = EgValue::from("t");
696 }
697 if self
698 .options
699 .get("phone_renewal")
700 .unwrap_or(&eg::NULL)
701 .boolish()
702 {
703 circ["phone_renewal"] = EgValue::from("t");
704 }
705 if self
706 .options
707 .get("desk_renewal")
708 .unwrap_or(&eg::NULL)
709 .boolish()
710 {
711 circ["desk_renewal"] = EgValue::from("t");
712 }
713 if self
714 .options
715 .get("auto_renewal")
716 .unwrap_or(&eg::NULL)
717 .boolish()
718 {
719 circ["auto_renewal"] = EgValue::from("t");
720 }
721
722 circ["renewal_remaining"] = EgValue::from(self.renewal_remaining);
723 circ["auto_renewal_remaining"] = EgValue::from(self.auto_renewal_remaining);
724 }
725
726 if self.circ_policy_unlimited {
727 circ["duration_rule"] = EgValue::from(C::CIRC_POLICY_UNLIMITED);
728 circ["recurring_fine_rule"] = EgValue::from(C::CIRC_POLICY_UNLIMITED);
729 circ["max_fine_rule"] = EgValue::from(C::CIRC_POLICY_UNLIMITED);
730 circ["renewal_remaining"] = EgValue::from(0);
731 circ["grace_period"] = EgValue::from(0);
732 } else if let Some(policy) = self.circ_policy_rules.as_ref() {
733 circ["duration"] = EgValue::from(policy.duration.to_string());
734 circ["duration_rule"] = policy.duration_rule["name"].clone();
735
736 circ["recurring_fine"] = EgValue::from(policy.recurring_fine);
737 circ["recurring_fine_rule"] = policy.recurring_fine_rule["name"].clone();
738 circ["fine_interval"] = policy.recurring_fine_rule["recurrence_interval"].clone();
739
740 circ["max_fine"] = EgValue::from(policy.max_fine);
741 circ["max_fine_rule"] = policy.max_fine_rule["name"].clone();
742
743 circ["renewal_remaining"] = policy.duration_rule["max_renewals"].clone();
744 circ["auto_renewal_remaining"] = policy.duration_rule["max_auto_renewals"].clone();
745
746 circ["grace_period"] = policy.recurring_fine_rule["grace_period"].clone();
748 } else {
749 return Err("Cannot build circ without a policy".into());
750 }
751
752 self.circ = Some(circ);
754
755 Ok(())
756 }
757
758 fn apply_due_date(&mut self) -> EgResult<()> {
759 let is_manual = self.set_manual_due_date()?;
760
761 if !is_manual {
762 self.set_initial_due_date()?;
763 }
764
765 let shift_to_start = self.apply_booking_due_date(is_manual)?;
766
767 if !is_manual {
768 self.extend_due_date(shift_to_start)?;
769 }
770
771 Ok(())
772 }
773
774 fn set_manual_due_date(&mut self) -> EgResult<bool> {
776 let due_val = match self.options.get("due_date") {
777 Some(d) => d.clone(),
778 None => return Ok(false),
779 };
780
781 let circ_lib = self.circ_lib;
782
783 if !self
784 .editor()
785 .allowed_at("CIRC_OVERRIDE_DUE_DATE", circ_lib)?
786 {
787 return Err(self.editor().die_event());
788 }
789
790 self.circ.as_mut().unwrap()["due_date"] = due_val;
791
792 Ok(true)
793 }
794
795 fn set_initial_due_date(&mut self) -> EgResult<()> {
797 let policy = match self.circ_policy_rules.as_ref() {
799 Some(p) => p,
800 None => return Ok(()),
801 };
802
803 let timezone = self
804 .settings
805 .get_value("lib.timezone")?
806 .as_str()
807 .unwrap_or("local");
808
809 let start_date = match self.circ.as_ref().unwrap()["xact_start"].as_str() {
810 Some(d) => date::parse_datetime(d)?,
811 None => date::now(),
812 };
813
814 let start_date = date::set_timezone(start_date, timezone)?;
815
816 let dur_secs = date::interval_to_seconds(&policy.duration)?;
817
818 let mut due_date = start_date + Duration::from_secs(dur_secs as u64);
819
820 if let Some(hdd) = policy.hard_due_date.as_ref() {
821 let cdate_str = hdd["ceiling_date"].as_str().unwrap();
822 let cdate = date::parse_datetime(cdate_str)?;
823 let force = hdd["forceto"].boolish();
824
825 if cdate > date::now() && (cdate < due_date || force) {
826 due_date = cdate;
827 }
828 }
829
830 self.circ.as_mut().unwrap()["due_date"] = EgValue::from(date::to_iso(&due_date));
831
832 Ok(())
833 }
834
835 fn apply_booking_due_date(&mut self, is_manual: bool) -> EgResult<bool> {
838 if !self.is_booking_enabled() {
839 return Ok(false);
840 }
841
842 let due_date = match self.circ.as_ref().unwrap()["due_date"].as_str() {
843 Some(s) => s.to_string(),
844 None => return Ok(false),
845 };
846
847 let query = eg::hash! {"barcode": self.copy()["barcode"].clone()};
848 let flesh = eg::hash! {"flesh": 1, "flesh_fields": {"brsrc": ["type"]}};
849
850 let resource = match self.editor().search_with_ops("brsrc", query, flesh)?.pop() {
851 Some(r) => r,
852 None => return Ok(false),
853 };
854
855 let stop_circ = self
856 .settings
857 .get_value("circ.booking_reservation.stop_circ")?
858 .boolish();
859
860 let query = eg::hash! {
861 "resource": resource["id"].clone(),
862 "search_start": "now",
863 "search_end": due_date.as_str(),
864 "fields": {
865 "cancel_time": eg::NULL,
866 "return_time": eg::NULL,
867 }
868 };
869
870 let booking_ids_op = self.editor().client_mut().send_recv_one(
871 "open-ils.booking",
872 "open-ils.booking.reservations.filtered_id_list",
873 query,
874 )?;
875
876 let booking_ids = match booking_ids_op {
877 Some(i) => i,
878 None => return Ok(false),
879 };
880
881 if !booking_ids.is_array() || booking_ids.is_empty() {
882 return Ok(false);
883 }
884
885 let due_date_dt = date::parse_datetime(&due_date)?;
887 let now_dt = date::now();
888 let mut bookings = Vec::new();
889
890 for id in booking_ids.members() {
893 let booking = self
894 .editor()
895 .retrieve("bresv", id.clone())?
896 .ok_or_else(|| self.editor().die_event())?;
897
898 let booking_start = date::parse_datetime(booking["start_time"].as_str().unwrap())?;
899
900 if booking_start < now_dt || stop_circ {
903 self.exit_err_on_event_code("COPY_RESERVED")?;
904 }
905
906 bookings.push(booking);
907 }
908
909 if is_manual {
910 return Ok(false);
916 }
917
918 let shorten_by = match resource["type"]["elbow_room"].as_str() {
920 Some(s) => s,
921 None => match self
922 .settings
923 .get_value("circ.booking_reservation.default_elbow_room")?
924 .as_str()
925 {
926 Some(s) => s,
927 None => return Ok(false),
928 },
929 };
930
931 let interval = date::interval_to_seconds(shorten_by)?;
934 let due_date_dt = due_date_dt - Duration::from_secs(interval as u64);
935
936 if due_date_dt < now_dt {
937 self.exit_err_on_event_code("COPY_RESERVED")?;
938 }
939
940 let mut duration = due_date_dt.timestamp() - now_dt.timestamp();
942 if duration % 86400 == 0 {
943 duration += 1;
947 }
948
949 let circ = self.circ.as_mut().unwrap();
950 circ["duration"] = EgValue::from(format!("{duration} seconds"));
951 circ["due_date"] = EgValue::from(date::to_iso(&due_date_dt));
952
953 Ok(true)
955 }
956
957 fn extend_due_date(&mut self, _shift_to_start: bool) -> EgResult<()> {
959 if self.is_renewal() {
960 self.extend_renewal_due_date()?;
961 }
962
963 let due_date_str = match self.circ.as_ref().unwrap()["due_date"].as_str() {
964 Some(s) => s,
965 None => return Ok(()),
966 };
967
968 let due_date_dt = date::parse_datetime(due_date_str)?;
969
970 let circ_lib = self.circ_lib;
971 let org_open_data = org::next_open_date(self.editor(), circ_lib, &due_date_dt)?;
972
973 let due_date_dt = match org_open_data {
974 org::OrgOpenState::Never | org::OrgOpenState::Open => return Ok(()),
976 org::OrgOpenState::OpensOnDate(d) => d,
977 };
978
979 log::info!("{self} bumping due date to avoid closures: {}", due_date_dt);
986
987 self.circ.as_mut().unwrap()["due_date"] = EgValue::from(date::to_iso(&due_date_dt));
988
989 Ok(())
990 }
991
992 fn extend_renewal_due_date(&mut self) -> EgResult<()> {
995 let policy = match self.circ_policy_rules.as_ref() {
996 Some(p) => p,
997 None => return Ok(()),
998 };
999
1000 let renew_extend_min_res = policy.matchpoint["renew_extend_min_interval"].string();
1002
1003 if !policy.matchpoint["renew_extends_due_date"].boolish() {
1004 return Ok(());
1006 }
1007
1008 let due_date_str = match self.circ.as_ref().unwrap()["due_date"].as_str() {
1009 Some(d) => d,
1010 None => return Ok(()),
1011 };
1012
1013 let due_date = date::parse_datetime(due_date_str)?;
1014
1015 let parent_circ = self
1016 .parent_circ
1017 .ok_or_else(|| "Renewals require a parent circ".to_string())?;
1018
1019 let prev_circ = match self.editor().retrieve("circ", EgValue::from(parent_circ))? {
1020 Some(c) => c,
1021 None => return Err(self.editor().die_event()),
1022 };
1023
1024 let start_time_str = prev_circ["xact_start"].as_str().expect("required");
1025 let start_time = date::parse_datetime(start_time_str)?;
1026
1027 let prev_due_date_str = prev_circ["due_date"].as_str().expect("required");
1028 let prev_due_date = date::parse_datetime(prev_due_date_str)?;
1029
1030 let now_time = date::now();
1031
1032 if prev_due_date < now_time {
1033 return Ok(());
1035 }
1036
1037 if let Ok(intvl) = renew_extend_min_res {
1040 let min_duration = date::interval_to_seconds(&intvl)?;
1041 let co_duration = now_time - start_time;
1042
1043 if co_duration.num_seconds() < min_duration {
1044 let due = if due_date < prev_due_date {
1051 prev_due_date
1052 } else {
1053 due_date
1054 };
1055
1056 self.circ.as_mut().unwrap()["due_date"] = EgValue::from(date::to_iso(&due));
1057
1058 return Ok(());
1059 }
1060 }
1061
1062 let remaining_duration = prev_due_date - now_time;
1067
1068 let due_date = due_date + remaining_duration;
1069
1070 let due = if due_date < prev_due_date {
1074 prev_due_date
1075 } else {
1076 due_date
1077 };
1078
1079 log::info!(
1080 "{self} renewal due date extension landed on due date: {}",
1081 due
1082 );
1083
1084 self.circ.as_mut().unwrap()["due_date"] = EgValue::from(date::to_iso(&due));
1085
1086 Ok(())
1087 }
1088
1089 fn save_checkout_circ(&mut self) -> EgResult<()> {
1090 let circ = self.circ.as_ref().unwrap().clone();
1093 let clone = EgValue::create("circ", circ)?;
1094
1095 log::debug!("{self} creating circulation {}", clone.dump());
1096
1097 self.circ = Some(self.editor().create(clone)?);
1099
1100 self.update_copy(eg::hash! {"status": C::COPY_STATUS_CHECKED_OUT})?;
1102
1103 Ok(())
1104 }
1105
1106 fn apply_limit_groups(&mut self) -> EgResult<()> {
1107 let limit_groups = match self.circ_policy_rules.as_ref() {
1108 Some(p) => match p.limit_groups.as_ref() {
1109 Some(g) => g,
1110 None => return Ok(()),
1111 },
1112 None => return Ok(()),
1113 };
1114
1115 let query = eg::hash! {
1116 "from": [
1117 "action.link_circ_limit_groups",
1118 self.circ.as_ref().unwrap()["id"].clone(),
1119 limit_groups.clone()
1120 ]
1121 };
1122
1123 self.editor().json_query(query)?;
1124
1125 Ok(())
1126 }
1127
1128 fn is_deposit(&self) -> bool {
1129 if let Some(copy) = self.copy.as_ref() {
1130 if let Some(amount) = copy["deposit_amount"].as_f64() {
1131 return amount > 0.0 && copy["deposit"].boolish();
1132 }
1133 }
1134 false
1135 }
1136
1137 fn is_rental(&self) -> bool {
1139 if let Some(copy) = self.copy.as_ref() {
1140 if let Some(amount) = copy["deposit_amount"].as_f64() {
1141 return amount > 0.0 && !copy["deposit"].boolish();
1142 }
1143 }
1144 false
1145 }
1146
1147 fn apply_deposit_fee(&mut self) -> EgResult<()> {
1148 let is_deposit = self.is_deposit();
1149 let is_rental = self.is_rental();
1150
1151 if !is_deposit && !is_rental {
1152 return Ok(());
1153 }
1154
1155 let deposit_amount = self.copy()["deposit_amount"].as_f64().unwrap();
1157
1158 let skip_deposit_fee = self.settings.get_value("skip_deposit_fee")?.boolish();
1159 if is_deposit && (skip_deposit_fee || self.is_deposit_exempt()?) {
1160 return Ok(());
1161 }
1162
1163 let skip_rental_fee = self.settings.get_value("skip_rental_fee")?.boolish();
1164 if is_rental && (skip_rental_fee | self.is_rental_exempt()?) {
1165 return Ok(());
1166 }
1167
1168 let mut btype = C::BTYPE_DEPOSIT;
1169 let mut btype_label = C::BTYPE_LABEL_DEPOSIT;
1170
1171 if is_rental {
1172 btype = C::BTYPE_RENTAL;
1173 btype_label = C::BTYPE_LABEL_RENTAL;
1174 }
1175
1176 let circ_id = self.circ.as_ref().expect("Circ is Set").id()?;
1177
1178 let bill = billing::create_bill(
1179 self.editor(),
1180 deposit_amount,
1181 billing::BillingType {
1182 id: btype,
1183 label: btype_label.to_string(),
1184 },
1185 circ_id,
1186 Some(C::BTYPE_NOTE_SYSTEM),
1187 None,
1188 None,
1189 )?;
1190
1191 if is_deposit {
1192 self.deposit_billing = Some(bill);
1193 } else {
1194 self.rental_billing = Some(bill);
1195 }
1196
1197 Ok(())
1198 }
1199
1200 fn is_deposit_exempt(&mut self) -> EgResult<bool> {
1201 let profile = self.patron.as_ref().unwrap()["profile"].id()?;
1202
1203 let groups = self.settings.get_value("circ.deposit.exempt_groups")?;
1204
1205 if !groups.is_array() || groups.is_empty() {
1206 return Ok(false);
1207 }
1208
1209 let mut parent_ids = Vec::new();
1210 for grp in groups.members() {
1211 parent_ids.push(grp.id()?);
1212 }
1213
1214 self.is_group_descendant(profile, parent_ids.as_slice())
1215 }
1216
1217 fn is_rental_exempt(&mut self) -> EgResult<bool> {
1218 let profile = self.patron.as_ref().unwrap()["profile"].id()?;
1219
1220 let groups = self.settings.get_value("circ.rental.exempt_groups")?;
1221
1222 if !groups.is_array() || groups.is_empty() {
1223 return Ok(false);
1224 }
1225
1226 let mut parent_ids = Vec::new();
1227 for grp in groups.members() {
1228 parent_ids.push(grp.id()?);
1229 }
1230
1231 self.is_group_descendant(profile, parent_ids.as_slice())
1232 }
1233
1234 fn is_group_descendant(&mut self, child_id: i64, parent_ids: &[i64]) -> EgResult<bool> {
1237 let query = eg::hash! {"from": ["permission.grp_ancestors", child_id] };
1238 let ancestors = self.editor().json_query(query)?;
1239 for parent_id in parent_ids {
1240 for grp in &ancestors {
1241 if grp.id()? == *parent_id {
1242 return Ok(true);
1243 }
1244 }
1245 }
1246
1247 Ok(false)
1248 }
1249
1250 fn handle_checkout_holds(&mut self) -> EgResult<()> {
1253 if self.is_noncat {
1254 return Ok(());
1255 }
1256
1257 let mut maybe_hold = self.checkout_is_for_hold.take();
1260
1261 if maybe_hold.is_none() {
1262 maybe_hold = self.handle_targeted_hold()?;
1263 }
1264
1265 if maybe_hold.is_none() {
1266 maybe_hold = self.find_related_user_hold()?;
1267 }
1268
1269 let mut hold = match maybe_hold.take() {
1270 Some(h) => h,
1271 None => return Ok(()),
1272 };
1273
1274 self.check_hold_fulfill_blocks()?;
1275
1276 let hold_id = hold.id()?;
1277
1278 log::info!("{self} fulfilling hold {hold_id}");
1279
1280 hold["hopeless_date"].take();
1281 hold["current_copy"] = EgValue::from(self.copy_id);
1282 hold["fulfillment_time"] = EgValue::from("now");
1283 hold["fulfillment_staff"] = EgValue::from(self.requestor_id()?);
1284 hold["fulfillment_lib"] = EgValue::from(self.circ_lib);
1285
1286 if hold["capture_time"].is_null() {
1287 hold["capture_time"] = EgValue::from("now");
1288 }
1289
1290 self.editor().create(hold)?;
1291
1292 self.fulfilled_hold_ids = Some(vec![hold_id]);
1293
1294 Ok(())
1295 }
1296
1297 fn handle_targeted_hold(&mut self) -> EgResult<Option<EgValue>> {
1301 let mut hold = match self.hold_found_for_alt_patron.take() {
1302 Some(h) => h,
1303 None => return Ok(None),
1304 };
1305
1306 hold["clear_prev_check_time"].take();
1307 hold["clear_current_copy"].take();
1308 hold["clear_capture_time"].take();
1309 hold["clear_shelf_time"].take();
1310 hold["clear_shelf_expire_time"].take();
1311 hold["clear_current_shelf_lib"].take();
1312
1313 log::info!(
1314 "{self} un-targeting hold {} because our copy is checking out",
1315 hold["id"],
1316 );
1317
1318 self.editor().update(hold).map(|_| None)
1319 }
1320
1321 fn find_related_user_hold(&mut self) -> EgResult<Option<EgValue>> {
1339 if self.is_precat_copy() {
1340 return Ok(None);
1341 }
1342
1343 if !self
1344 .settings
1345 .get_value("circ.checkout_fills_related_hold")?
1346 .boolish()
1347 {
1348 return Ok(None);
1349 }
1350
1351 let copy_id = self.copy_id;
1352 let patron_id = self.patron_id;
1353
1354 let query = eg::hash! {
1356 "select": {"ahr": ["id"]},
1357 "from": {
1358 "ahr": {
1359 "ahcm": {
1360 "field": "hold",
1361 "fkey": "id"
1362 },
1363 "acp": {
1364 "field": "id",
1365 "fkey": "current_copy",
1366 "type": "left" }
1368 }
1369 },
1370 "where": {
1371 "+ahr": {
1372 "usr": patron_id,
1373 "fulfillment_time": eg::NULL,
1374 "cancel_time": eg::NULL,
1375 "-or": [
1376 {"expire_time": eg::NULL},
1377 {"expire_time": {">": "now"}}
1378 ]
1379 },
1380 "+ahcm": {
1381 "target_copy": copy_id,
1382 },
1383 "+acp": {
1384 "-or": [
1385 {"id": eg::NULL}, {"status": {"!=": C::COPY_STATUS_ON_HOLDS_SHELF}},
1387 ]
1388 }
1389 },
1390 "order_by": {"ahr": {"request_time": {"direction": "asc"}}},
1391 "limit": 1
1392 };
1393
1394 if let Some(hold) = self.editor().json_query(query)?.pop() {
1395 return self.editor().retrieve("ahr", hold["id"].clone());
1396 }
1397
1398 if self
1399 .settings
1400 .get_value("circ.checkout_fills_related_hold_exact_match_only")?
1401 .boolish()
1402 {
1403 return Ok(None);
1405 }
1406
1407 let circ_lib = self.circ_lib;
1411 let patron_id = self.patron_id;
1412 let copy_id = self.copy_id;
1413
1414 let hold_data = holds::related_to_copy(
1415 self.editor(),
1416 copy_id,
1417 Some(circ_lib),
1418 None, Some(patron_id),
1420 Some(false), )?;
1422
1423 if hold_data.is_empty() {
1424 return Ok(None);
1425 }
1426
1427 let record_id = self.copy()["call_number"]["record"].int()?;
1432 let volume_id = self.copy()["call_number"].id()?;
1433
1434 for hold in hold_data.iter() {
1435 let target = hold.target();
1436
1437 if hold.hold_type() == holds::HoldType::Title && target == record_id {
1440 return self.editor().retrieve("ahr", hold.id());
1441 }
1442
1443 if hold.hold_type() == holds::HoldType::Volume && target == volume_id {
1444 return self.editor().retrieve("ahr", hold.id());
1445 }
1446 }
1447
1448 Ok(None)
1449 }
1450
1451 fn check_hold_fulfill_blocks(&mut self) -> EgResult<()> {
1454 let home_ou = self.patron.as_ref().unwrap()["home_ou"].int()?;
1455 let copy_ou = self.copy()["circ_lib"].int()?;
1456
1457 let circ_lib = self.circ_lib;
1458 let ou_prox = org::proximity(self.editor(), home_ou, circ_lib)?.unwrap_or(-1);
1459
1460 let copy_prox = if copy_ou == circ_lib {
1461 ou_prox
1462 } else {
1463 org::proximity(self.editor(), copy_ou, circ_lib)?.unwrap_or(-1)
1464 };
1465
1466 let query = eg::hash! {
1467 "select": {"csp": ["name", "label"]},
1468 "from": {"ausp": "csp"},
1469 "where": {
1470 "+ausp": {
1471 "usr": self.patron_id,
1472 "org_unit": org::full_path(self.editor(), circ_lib, None)?,
1473 "-or": [
1474 {"stop_date": eg::NULL},
1475 {"stop_date": {">": "now"}}
1476 ]
1477 },
1478 "+csp": {
1479 "block_list": {"like": "%FULFILL%"},
1480 "-or": [
1481 {"ignore_proximity": eg::NULL},
1482 {"ignore_proximity": {"<": ou_prox}},
1483 {"ignore_proximity": {"<": copy_prox}}
1484 ]
1485 }
1486 }
1487 };
1488
1489 let penalties = self.editor().json_query(query)?;
1490 for pen in penalties {
1491 let mut evt = EgEvent::new(pen["name"].as_str().unwrap());
1492 if let Some(d) = pen["label"].as_str() {
1493 evt.set_desc(d);
1494 }
1495 self.add_event(evt);
1496 }
1497
1498 self.try_override_events()
1499 }
1500
1501 fn build_checkout_response(&mut self) -> EgResult<()> {
1502 let mut record = None;
1503 if !self.is_precat_copy() {
1504 let record_id = self.copy()["call_number"]["record"].int()?;
1505 record = Some(bib::map_to_mvr(self.editor(), record_id)?);
1506 }
1507
1508 let mut copy = self.copy().clone();
1509 let volume = copy["call_number"].take();
1510 copy.deflesh()?;
1511
1512 let circ = self.circ.as_ref().unwrap().clone();
1513 let patron = self.patron.as_ref().unwrap().clone();
1514 let patron_id = self.patron_id;
1515
1516 let patron_money = self.editor().retrieve("mus", patron_id)?;
1517
1518 let mut payload = eg::hash! {
1519 "copy": copy,
1520 "volume": volume,
1521 "record": record,
1522 "circ": circ,
1523 "patron": patron,
1524 "patron_money": patron_money,
1525 };
1526
1527 if let Some(list) = self.fulfilled_hold_ids.as_ref() {
1528 payload["holds_fulfilled"] = EgValue::from(list.clone());
1529 }
1530
1531 if let Some(bill) = self.deposit_billing.as_ref() {
1532 payload["deposit_billing"] = bill.clone();
1533 }
1534
1535 if let Some(bill) = self.rental_billing.as_ref() {
1536 payload["rental_billing"] = bill.clone();
1537 }
1538
1539 if let Some(pcirc) = self.parent_circ {
1541 let flesh = eg::hash! {
1542 "flesh": 1,
1543 "flesh_fields": {
1544 "circ": ["billable_transaction"],
1545 "mbt": ["summary"],
1546 }
1547 };
1548
1549 if let Some(circ) = self.editor().retrieve_with_ops("circ", pcirc, flesh)? {
1550 payload["parent_circ"] = circ;
1551 }
1552 }
1553
1554 let mut evt = EgEvent::success();
1555 evt.set_payload(payload);
1556 self.add_event(evt);
1557
1558 Ok(())
1559 }
1560}