1 package OpenILS::Application::Circ::Circulate;
2 use strict; use warnings;
3 use base 'OpenILS::Application';
4 use OpenSRF::EX qw(:try);
5 use OpenSRF::AppSession;
6 use OpenSRF::Utils::SettingsClient;
7 use OpenSRF::Utils::Logger qw(:logger);
8 use OpenSRF::Utils::Config;
9 use OpenILS::Const qw/:const/;
10 use OpenILS::Application::AppUtils;
12 my $U = "OpenILS::Application::AppUtils";
16 my $opac_renewal_use_circ_lib;
17 my $desk_renewal_use_circ_lib;
19 sub determine_booking_status {
20 unless (defined $booking_status) {
21 my $router_name = OpenSRF::Utils::Config
24 ->router_name || 'router';
26 my $ses = create OpenSRF::AppSession($router_name);
27 $booking_status = grep {$_ eq "open-ils.booking"} @{
28 $ses->request("opensrf.router.info.class.list")->gather(1)
31 $logger->info("booking status: " . ($booking_status ? "on" : "off"));
34 return $booking_status;
40 flesh_fields => {acp => ['call_number','parts','floating'], acn => ['record']}
43 # table of cases where suppressing a system-generated copy alerts
44 # should generate an override of an old-style event
45 my %COPY_ALERT_OVERRIDES = (
46 "CLAIMSRETURNED\tCHECKOUT" => ['CIRC_CLAIMS_RETURNED'],
47 "CLAIMSRETURNED\tCHECKIN" => ['CIRC_CLAIMS_RETURNED'],
48 "LOST\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
49 "LONGOVERDUE\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
50 "MISSING\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
51 "DAMAGED\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
52 "LOST_AND_PAID\tCHECKOUT" => ['COPY_NOT_AVAILABLE', 'OPEN_CIRCULATION_EXISTS']
57 __PACKAGE__->register_method(
58 method => "run_method",
59 api_name => "open-ils.circ.checkout.permit",
61 Determines if the given checkout can occur
62 @param authtoken The login session key
63 @param params A trailing hash of named params including
64 barcode : The copy barcode,
65 patron : The patron the checkout is occurring for,
66 renew : true or false - whether or not this is a renewal
67 @return The event that occurred during the permit check.
71 __PACKAGE__->register_method (
72 method => 'run_method',
73 api_name => 'open-ils.circ.checkout.permit.override',
74 signature => q/@see open-ils.circ.checkout.permit/,
78 __PACKAGE__->register_method(
79 method => "run_method",
80 api_name => "open-ils.circ.checkout",
83 @param authtoken The login session key
84 @param params A named hash of params including:
86 barcode If no copy is provided, the copy is retrieved via barcode
87 copyid If no copy or barcode is provide, the copy id will be use
88 patron The patron's id
89 noncat True if this is a circulation for a non-cataloted item
90 noncat_type The non-cataloged type id
91 noncat_circ_lib The location for the noncat circ.
92 precat The item has yet to be cataloged
93 dummy_title The temporary title of the pre-cataloded item
94 dummy_author The temporary authr of the pre-cataloded item
95 Default is the home org of the staff member
96 @return The SUCCESS event on success, any other event depending on the error
99 __PACKAGE__->register_method(
100 method => "run_method",
101 api_name => "open-ils.circ.checkin",
104 Generic super-method for handling all copies
105 @param authtoken The login session key
106 @param params Hash of named parameters including:
107 barcode - The copy barcode
108 force - If true, copies in bad statuses will be checked in and give good statuses
109 noop - don't capture holds or put items into transit
110 void_overdues - void all overdues for the circulation (aka amnesty)
115 __PACKAGE__->register_method(
116 method => "run_method",
117 api_name => "open-ils.circ.checkin.override",
118 signature => q/@see open-ils.circ.checkin/
121 __PACKAGE__->register_method(
122 method => "run_method",
123 api_name => "open-ils.circ.renew.override",
124 signature => q/@see open-ils.circ.renew/,
127 __PACKAGE__->register_method(
128 method => "run_method",
129 api_name => "open-ils.circ.renew.auto",
130 signature => q/@see open-ils.circ.renew/,
132 The open-ils.circ.renew.auto API is deprecated. Please use the
133 auto_renew => 1 option to open-ils.circ.renew, instead.
137 __PACKAGE__->register_method(
138 method => "run_method",
139 api_name => "open-ils.circ.renew",
140 notes => <<" NOTES");
141 PARAMS( authtoken, circ => circ_id );
142 open-ils.circ.renew(login_session, circ_object);
143 Renews the provided circulation. login_session is the requestor of the
144 renewal and if the logged in user is not the same as circ->usr, then
145 the logged in user must have RENEW_CIRC permissions.
148 __PACKAGE__->register_method(
149 method => "run_method",
150 api_name => "open-ils.circ.checkout.full"
152 __PACKAGE__->register_method(
153 method => "run_method",
154 api_name => "open-ils.circ.checkout.full.override"
156 __PACKAGE__->register_method(
157 method => "run_method",
158 api_name => "open-ils.circ.reservation.pickup"
160 __PACKAGE__->register_method(
161 method => "run_method",
162 api_name => "open-ils.circ.reservation.return"
164 __PACKAGE__->register_method(
165 method => "run_method",
166 api_name => "open-ils.circ.reservation.return.override"
168 __PACKAGE__->register_method(
169 method => "run_method",
170 api_name => "open-ils.circ.checkout.inspect",
171 desc => q/Returns the circ matrix test result and, on success, the rule set and matrix test object/
176 my( $self, $conn, $auth, $args ) = @_;
177 translate_legacy_args($args);
178 $args->{override_args} = { all => 1 } unless defined $args->{override_args};
179 $args->{new_copy_alerts} ||= $self->api_level > 1 ? 1 : 0;
180 my $api = $self->api_name;
183 OpenILS::Application::Circ::Circulator->new($auth, %$args);
185 return circ_events($circulator) if $circulator->bail_out;
187 $circulator->use_booking(determine_booking_status());
189 # --------------------------------------------------------------------------
190 # First, check for a booking transit, as the barcode may not be a copy
191 # barcode, but a resource barcode, and nothing else in here will work
192 # --------------------------------------------------------------------------
194 if ($circulator->use_booking && (my $bc = $circulator->copy_barcode) && $api !~ /checkout|inspect/) { # do we have a barcode?
195 my $resources = $circulator->editor->search_booking_resource( { barcode => $bc } ); # any resources by this barcode?
196 if (@$resources) { # yes!
198 my $res_id_list = [ map { $_->id } @$resources ];
199 my $transit = $circulator->editor->search_action_reservation_transit_copy(
201 { target_copy => $res_id_list, dest => $circulator->circ_lib, dest_recv_time => undef, cancel_time => undef },
202 { order_by => { artc => 'source_send_time' }, limit => 1 }
204 )->[0]; # Any transit for this barcode?
206 if ($transit) { # yes! unwrap it.
208 my $reservation = $circulator->editor->retrieve_booking_reservation( $transit->reservation );
209 my $res_type = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
211 my $success_event = new OpenILS::Event(
212 "SUCCESS", "payload" => {"reservation" => $reservation}
214 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
215 if (my $copy = $circulator->editor->search_asset_copy([
216 { barcode => $bc, deleted => 'f' }, $MK_ENV_FLESH
217 ])->[0]) { # got a copy
218 $copy->status( $transit->copy_status );
219 $copy->editor($circulator->editor->requestor->id);
220 $copy->edit_date('now');
221 $circulator->editor->update_asset_copy($copy);
222 $success_event->{"payload"}->{"record"} =
223 $U->record_to_mvr($copy->call_number->record);
224 $success_event->{"payload"}->{"volume"} = $copy->call_number;
225 $copy->call_number($copy->call_number->id);
226 $success_event->{"payload"}->{"copy"} = $copy;
230 $transit->dest_recv_time('now');
231 $circulator->editor->update_action_reservation_transit_copy( $transit );
233 $circulator->editor->commit;
234 # Formerly this branch just stopped here. Argh!
235 $conn->respond_complete($success_event);
241 if ($circulator->use_booking) {
242 $circulator->is_res_checkin($circulator->is_checkin(1))
243 if $api =~ /reservation.return/ or (
244 $api =~ /checkin/ and $circulator->seems_like_reservation()
247 $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
250 $circulator->is_renewal(1) if $api =~ /renew/;
251 $circulator->auto_renewal(1) if $api =~ /renew.auto/;
252 $circulator->is_checkin(1) if $api =~ /checkin/;
253 $circulator->is_checkout(1) if $api =~ /checkout/;
254 $circulator->override(1) if $api =~ /override/o;
256 $circulator->mk_env();
257 $circulator->noop(1) if $circulator->claims_never_checked_out;
259 return circ_events($circulator) if $circulator->bail_out;
261 if( $api =~ /checkout\.permit/ ) {
262 $circulator->do_permit();
264 } elsif( $api =~ /checkout.full/ ) {
266 # requesting a precat checkout implies that any required
267 # overrides have been performed. Go ahead and re-override.
268 $circulator->skip_permit_key(1);
269 $circulator->override(1) if ( $circulator->request_precat && $circulator->editor->allowed('CREATE_PRECAT') );
270 $circulator->do_permit();
271 $circulator->is_checkout(1);
272 unless( $circulator->bail_out ) {
273 $circulator->events([]);
274 $circulator->do_checkout();
277 } elsif( $circulator->is_res_checkout ) {
278 $circulator->do_reservation_pickup();
280 } elsif( $api =~ /inspect/ ) {
281 my $data = $circulator->do_inspect();
282 $circulator->editor->rollback;
285 } elsif( $api =~ /checkout/ ) {
286 $circulator->do_checkout();
288 } elsif( $circulator->is_res_checkin ) {
289 $circulator->do_reservation_return();
290 $circulator->do_checkin() if ($circulator->copy());
291 } elsif( $api =~ /checkin/ ) {
292 $circulator->do_checkin();
294 } elsif( $api =~ /renew/ ) {
295 $circulator->do_renew($api);
298 if( $circulator->bail_out ) {
301 # make sure no success event accidentally slip in
303 [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
306 my @e = @{$circulator->events};
307 push( @ee, $_->{textcode} ) for @e;
308 $logger->info("circulator: bailing out with events: " . (join ", ", @ee));
310 $circulator->editor->rollback;
314 # checkin and reservation return can result in modifications to
315 # actor.usr.claims_never_checked_out_count without also modifying
316 # actor.last_xact_id. Perform a no-op update on the patron to
317 # force an update to last_xact_id.
318 if ($circulator->claims_never_checked_out && $circulator->patron) {
319 $circulator->editor->update_actor_user(
320 $circulator->editor->retrieve_actor_user($circulator->patron->id))
321 or return $circulator->editor->die_event;
324 $circulator->editor->commit;
327 $conn->respond_complete(circ_events($circulator));
329 return undef if $circulator->bail_out;
331 $circulator->do_hold_notify($circulator->notify_hold)
332 if $circulator->notify_hold;
333 $circulator->retarget_holds if $circulator->retarget;
334 $circulator->append_reading_list;
335 $circulator->make_trigger_events;
342 my @e = @{$circ->events};
343 # if we have multiple events, SUCCESS should not be one of them;
344 @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
345 return (@e == 1) ? $e[0] : \@e;
349 sub translate_legacy_args {
352 if( $$args{barcode} ) {
353 $$args{copy_barcode} = $$args{barcode};
354 delete $$args{barcode};
357 if( $$args{copyid} ) {
358 $$args{copy_id} = $$args{copyid};
359 delete $$args{copyid};
362 if( $$args{patronid} ) {
363 $$args{patron_id} = $$args{patronid};
364 delete $$args{patronid};
367 if( $$args{patron} and !ref($$args{patron}) ) {
368 $$args{patron_id} = $$args{patron};
369 delete $$args{patron};
373 if( $$args{noncat} ) {
374 $$args{is_noncat} = $$args{noncat};
375 delete $$args{noncat};
378 if( $$args{precat} ) {
379 $$args{is_precat} = $$args{request_precat} = $$args{precat};
380 delete $$args{precat};
386 # --------------------------------------------------------------------------
387 # This package actually manages all of the circulation logic
388 # --------------------------------------------------------------------------
389 package OpenILS::Application::Circ::Circulator;
390 use strict; use warnings;
391 use vars q/$AUTOLOAD/;
393 use OpenILS::Utils::Fieldmapper;
394 use OpenSRF::Utils::Cache;
395 use Digest::MD5 qw(md5_hex);
396 use DateTime::Format::ISO8601;
397 use OpenILS::Utils::PermitHold;
398 use OpenILS::Utils::DateTime qw/:datetime/;
399 use OpenSRF::Utils::SettingsClient;
400 use OpenILS::Application::Circ::Holds;
401 use OpenILS::Application::Circ::Transit;
402 use OpenSRF::Utils::Logger qw(:logger);
403 use OpenILS::Utils::CStoreEditor qw/:funcs/;
404 use OpenILS::Const qw/:const/;
405 use OpenILS::Utils::Penalty;
406 use OpenILS::Application::Circ::CircCommon;
409 my $CC = "OpenILS::Application::Circ::CircCommon";
410 my $holdcode = "OpenILS::Application::Circ::Holds";
411 my $transcode = "OpenILS::Application::Circ::Transit";
417 # --------------------------------------------------------------------------
418 # Add a pile of automagic getter/setter methods
419 # --------------------------------------------------------------------------
420 my @AUTOLOAD_FIELDS = qw/
433 overrides_per_copy_alerts
474 recurring_fines_level
479 auto_renewal_remaining
488 cancelled_hold_transit
496 circ_matrix_matchpoint
507 claims_never_checked_out
520 dont_change_lost_zero
522 needs_lost_bill_handling
528 my $type = ref($self) or die "$self is not an object";
530 my $name = $AUTOLOAD;
533 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
534 $logger->error("circulator: $type: invalid autoload field: $name");
535 die "$type: invalid autoload field: $name\n"
540 *{"${type}::${name}"} = sub {
543 $s->{$name} = $v if defined $v;
547 return $self->$name($data);
552 my( $class, $auth, %args ) = @_;
553 $class = ref($class) || $class;
554 my $self = bless( {}, $class );
557 $self->editor(new_editor(xact => 1, authtoken => $auth));
559 unless( $self->editor->checkauth ) {
560 $self->bail_on_events($self->editor->event);
564 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
566 $self->$_($args{$_}) for keys %args;
569 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
571 # if this is a renewal, default to desk_renewal
572 $self->desk_renewal(1) unless
573 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal
574 or $self->auto_renewal;
576 $self->capture('') unless $self->capture;
578 unless(%user_groups) {
579 my $gps = $self->editor->retrieve_all_permission_grp_tree;
580 %user_groups = map { $_->id => $_ } @$gps;
587 # --------------------------------------------------------------------------
588 # True if we should discontinue processing
589 # --------------------------------------------------------------------------
591 my( $self, $bool ) = @_;
592 if( defined $bool ) {
593 $logger->info("circulator: BAILING OUT") if $bool;
594 $self->{bail_out} = $bool;
596 return $self->{bail_out};
601 my( $self, @evts ) = @_;
604 $e->{payload} = $self->copy if
605 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
607 $logger->info("circulator: pushing event ".$e->{textcode});
608 push( @{$self->events}, $e ) unless
609 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
615 return '' if $self->skip_permit_key;
616 my $key = md5_hex( time() . rand() . "$$" );
617 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
618 return $self->permit_key($key);
621 sub check_permit_key {
623 return 1 if $self->skip_permit_key;
624 my $key = $self->permit_key;
625 return 0 unless $key;
626 my $k = "oils_permit_key_$key";
627 my $one = $self->cache_handle->get_cache($k);
628 $self->cache_handle->delete_cache($k);
629 return ($one) ? 1 : 0;
632 sub seems_like_reservation {
635 # Some words about the following method:
636 # 1) It requires the VIEW_USER permission, but that's not an
637 # issue, right, since all staff should have that?
638 # 2) It returns only one reservation at a time, even if an item can be
639 # and is currently overbooked. Hmmm....
640 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
641 my $result = $booking_ses->request(
642 "open-ils.booking.reservations.by_returnable_resource_barcode",
643 $self->editor->authtoken,
646 $booking_ses->disconnect;
648 return $self->bail_on_events($result) if defined $U->event_code($result);
651 $self->reservation(shift @$result);
659 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
660 sub save_trimmed_copy {
661 my ($self, $copy) = @_;
664 $self->volume($copy->call_number);
665 $self->title($self->volume->record);
666 $self->copy->call_number($self->volume->id);
667 $self->volume->record($self->title->id);
668 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
669 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
670 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
671 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
675 sub collect_user_copy_alerts {
677 my $e = $self->editor;
680 my $alerts = $e->search_asset_copy_alert([
681 {copy => $self->copy->id, ack_time => undef},
682 {flesh => 1, flesh_fields => { aca => [ qw/ alert_type / ] }}
684 if (ref $alerts eq "ARRAY") {
685 $logger->info("circulator: found " . scalar(@$alerts) . " alerts for copy " .
687 $self->user_copy_alerts($alerts);
692 sub filter_user_copy_alerts {
695 my $e = $self->editor;
697 if(my $alerts = $self->user_copy_alerts) {
699 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
700 my $suppressions = $e->search_actor_copy_alert_suppress(
701 {org => $suppress_orgs}
705 foreach my $a (@$alerts) {
706 # filter on event type
707 if (defined $a->alert_type) {
708 next if ($a->alert_type->event eq 'CHECKIN' && !$self->is_checkin && !$self->is_renewal);
709 next if ($a->alert_type->event eq 'CHECKOUT' && !$self->is_checkout && !$self->is_renewal);
710 next if (defined $a->alert_type->in_renew && $U->is_true($a->alert_type->in_renew) && !$self->is_renewal);
711 next if (defined $a->alert_type->in_renew && !$U->is_true($a->alert_type->in_renew) && $self->is_renewal);
714 # filter on suppression
715 next if (grep { $a->alert_type->id == $_->alert_type} @$suppressions);
717 # filter on "only at circ lib"
718 if (defined $a->alert_type->at_circ) {
719 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
720 $self->copy->circ_lib->id : $self->copy->circ_lib;
721 my $orgs = $U->get_org_descendants($copy_circ_lib);
723 if ($U->is_true($a->alert_type->invert_location)) {
724 next if (grep {$_ == $self->circ_lib} @$orgs);
726 next unless (grep {$_ == $self->circ_lib} @$orgs);
730 # filter on "only at owning lib"
731 if (defined $a->alert_type->at_owning) {
732 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
733 $self->volume->owning_lib->id : $self->volume->owning_lib;
734 my $orgs = $U->get_org_descendants($copy_owning_lib);
736 if ($U->is_true($a->alert_type->invert_location)) {
737 next if (grep {$_ == $self->circ_lib} @$orgs);
739 next unless (grep {$_ == $self->circ_lib} @$orgs);
743 $a->alert_type->next_status([$U->unique_unnested_numbers($a->alert_type->next_status)]);
745 push @final_alerts, $a;
748 $self->user_copy_alerts(\@final_alerts);
752 sub generate_system_copy_alerts {
754 return unless($self->copy);
756 # don't create system copy alerts if the copy
757 # is in a normal state; we're assuming that there's
758 # never a need to generate a popup for each and every
759 # checkin or checkout of normal items. If this assumption
760 # proves false, then we'll need to add a way to explicitly specify
761 # that a copy alert type should never generate a system copy alert
762 return if $self->copy_state eq 'NORMAL';
764 my $e = $self->editor;
766 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
767 my $suppressions = $e->search_actor_copy_alert_suppress(
768 {org => $suppress_orgs}
771 # events we care about ...
773 push(@$event, 'CHECKIN') if $self->is_checkin;
774 push(@$event, 'CHECKOUT') if $self->is_checkout;
775 return unless scalar(@$event);
777 my $alert_orgs = $U->get_org_ancestors($self->circ_lib);
778 my $alert_types = $e->search_config_copy_alert_type({
780 scope_org => $alert_orgs,
782 state => $self->copy_state,
783 '-or' => [ { in_renew => $self->is_renewal }, { in_renew => undef } ],
787 foreach my $a (@$alert_types) {
788 # filter on "only at circ lib"
789 if (defined $a->at_circ) {
790 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
791 $self->copy->circ_lib->id : $self->copy->circ_lib;
792 my $orgs = $U->get_org_descendants($copy_circ_lib);
794 if ($U->is_true($a->invert_location)) {
795 next if (grep {$_ == $self->circ_lib} @$orgs);
797 next unless (grep {$_ == $self->circ_lib} @$orgs);
801 # filter on "only at owning lib"
802 if (defined $a->at_owning) {
803 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
804 $self->volume->owning_lib->id : $self->volume->owning_lib;
805 my $orgs = $U->get_org_descendants($copy_owning_lib);
807 if ($U->is_true($a->invert_location)) {
808 next if (grep {$_ == $self->circ_lib} @$orgs);
810 next unless (grep {$_ == $self->circ_lib} @$orgs);
814 push @final_types, $a;
818 $logger->info("circulator: found " . scalar(@final_types) . " system alert types for copy" .
824 # keep track of conditions corresponding to suppressed
825 # system alerts, as these may be used to overridee
826 # certain old-style-events
827 my %auto_override_conditions = ();
828 foreach my $t (@final_types) {
829 if ($t->next_status) {
830 if (grep { $t->id == $_->alert_type } @$suppressions) {
833 $t->next_status([$U->unique_unnested_numbers($t->next_status)]);
837 my $alert = new Fieldmapper::asset::copy_alert ();
838 $alert->alert_type($t->id);
839 $alert->copy($self->copy->id);
841 $alert->create_staff($e->requestor->id);
842 $alert->create_time('now');
843 $alert->ack_staff($e->requestor->id);
844 $alert->ack_time('now');
846 $alert = $e->create_asset_copy_alert($alert);
850 $alert->alert_type($t->clone);
852 push(@{$self->next_copy_status}, @{$t->next_status}) if ($t->next_status);
853 if (grep {$_->alert_type == $t->id} @$suppressions) {
854 $auto_override_conditions{join("\t", $t->state, $t->event)} = 1;
856 push(@alerts, $alert) unless (grep {$_->alert_type == $t->id} @$suppressions);
859 $self->system_copy_alerts(\@alerts);
860 $self->overrides_per_copy_alerts(\%auto_override_conditions);
863 sub add_overrides_from_system_copy_alerts {
865 my $e = $self->editor;
867 foreach my $condition (keys %{$self->overrides_per_copy_alerts()}) {
868 if (exists $COPY_ALERT_OVERRIDES{$condition}) {
870 push @{$self->override_args->{events}}, @{ $COPY_ALERT_OVERRIDES{$condition} };
871 # special handling for long-overdue and lost checkouts
872 if (grep { $_ eq 'OPEN_CIRCULATION_EXISTS' } @{ $COPY_ALERT_OVERRIDES{$condition} }) {
873 my $state = (split /\t/, $condition, -1)[0];
875 if ($state eq 'LOST' or $state eq 'LOST_AND_PAID') {
876 $setting = 'circ.copy_alerts.forgive_fines_on_lost_checkin';
877 } elsif ($state eq 'LONGOVERDUE') {
878 $setting = 'circ.copy_alerts.forgive_fines_on_long_overdue_checkin';
882 my $forgive = $U->ou_ancestor_setting_value(
883 $self->circ_lib, $setting, $e
885 if ($U->is_true($forgive)) {
886 $self->void_overdues(1);
888 $self->noop(1); # do not attempt transits, just check it in
897 my $e = $self->editor;
899 $self->next_copy_status([]) unless (defined $self->next_copy_status);
900 $self->overrides_per_copy_alerts({}) unless (defined $self->overrides_per_copy_alerts);
902 # --------------------------------------------------------------------------
903 # Grab the fleshed copy
904 # --------------------------------------------------------------------------
905 unless($self->is_noncat) {
908 $copy = $e->retrieve_asset_copy(
909 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
911 } elsif( $self->copy_barcode ) {
913 $copy = $e->search_asset_copy(
914 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
915 } elsif( $self->reservation ) {
916 my $res = $e->json_query(
918 "select" => {"acp" => ["id"]},
923 "field" => "barcode",
927 "field" => "current_resource"
936 "id" => (ref $self->reservation) ?
937 $self->reservation->id : $self->reservation
942 if (ref $res eq "ARRAY" and scalar @$res) {
943 $logger->info("circulator: mapped reservation " .
944 $self->reservation . " to copy " . $res->[0]->{"id"});
945 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
950 $self->save_trimmed_copy($copy);
955 {from => ['asset.copy_state', $copy->id]}
956 )->[0]{'asset.copy_state'}
959 $self->generate_system_copy_alerts;
960 $self->add_overrides_from_system_copy_alerts;
961 $self->collect_user_copy_alerts;
962 $self->filter_user_copy_alerts;
965 # We can't renew if there is no copy
966 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
967 if $self->is_renewal;
972 # --------------------------------------------------------------------------
974 # --------------------------------------------------------------------------
978 flesh_fields => {au => [ qw/ card / ]}
981 if( $self->patron_id ) {
982 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
983 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
985 } elsif( $self->patron_barcode ) {
987 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
988 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
989 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
991 $patron = $e->retrieve_actor_user($card->usr)
992 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
994 # Use the card we looked up, not the patron's primary, for card active checks
995 $patron->card($card);
998 if( my $copy = $self->copy ) {
1000 $flesh->{flesh} = 2;
1001 $flesh->{flesh_fields}->{circ} = ['usr'];
1003 my $circ = $e->search_action_circulation([
1004 {target_copy => $copy->id, checkin_time => undef}, $flesh
1008 $patron = $circ->usr;
1009 $circ->usr($patron->id); # de-flesh for consistency
1015 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
1016 unless $self->patron($patron) or $self->is_checkin;
1018 unless($self->is_checkin) {
1020 # Check for inactivity and patron reg. expiration
1022 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
1023 unless $U->is_true($patron->active);
1025 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
1026 unless $U->is_true($patron->card->active);
1028 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1029 clean_ISO8601($patron->expire_date));
1031 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1032 if( CORE::time > $expire->epoch ) ;
1037 # --------------------------------------------------------------------------
1038 # Does the circ permit work
1039 # --------------------------------------------------------------------------
1043 $self->log_me("do_permit()");
1045 unless( $self->editor->requestor->id == $self->patron->id ) {
1046 return $self->bail_on_events($self->editor->event)
1047 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1050 $self->check_captured_holds();
1051 $self->do_copy_checks();
1052 return if $self->bail_out;
1053 $self->run_patron_permit_scripts();
1054 $self->run_copy_permit_scripts()
1055 unless $self->is_precat or $self->is_noncat;
1056 $self->check_item_deposit_events();
1057 $self->override_events();
1058 return if $self->bail_out;
1060 if($self->is_precat and not $self->request_precat) {
1062 OpenILS::Event->new(
1063 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1064 return $self->bail_out(1) unless $self->is_renewal;
1068 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1071 sub check_item_deposit_events {
1073 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
1074 if $self->is_deposit and not $self->is_deposit_exempt;
1075 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
1076 if $self->is_rental and not $self->is_rental_exempt;
1079 # returns true if the user is not required to pay deposits
1080 sub is_deposit_exempt {
1082 my $pid = (ref $self->patron->profile) ?
1083 $self->patron->profile->id : $self->patron->profile;
1084 my $groups = $U->ou_ancestor_setting_value(
1085 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1086 for my $grp (@$groups) {
1087 return 1 if $self->is_group_descendant($grp, $pid);
1092 # returns true if the user is not required to pay rental fees
1093 sub is_rental_exempt {
1095 my $pid = (ref $self->patron->profile) ?
1096 $self->patron->profile->id : $self->patron->profile;
1097 my $groups = $U->ou_ancestor_setting_value(
1098 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1099 for my $grp (@$groups) {
1100 return 1 if $self->is_group_descendant($grp, $pid);
1105 sub is_group_descendant {
1106 my($self, $p_id, $c_id) = @_;
1107 return 0 unless defined $p_id and defined $c_id;
1108 return 1 if $c_id == $p_id;
1109 while(my $grp = $user_groups{$c_id}) {
1110 $c_id = $grp->parent;
1111 return 0 unless defined $c_id;
1112 return 1 if $c_id == $p_id;
1117 sub check_captured_holds {
1119 my $copy = $self->copy;
1120 my $patron = $self->patron;
1122 return undef unless $copy;
1124 my $s = $U->copy_status($copy->status)->id;
1125 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1126 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1128 # Item is on the holds shelf, make sure it's going to the right person
1129 my $hold = $self->editor->search_action_hold_request(
1132 current_copy => $copy->id ,
1133 capture_time => { '!=' => undef },
1134 cancel_time => undef,
1135 fulfillment_time => undef
1139 flesh_fields => { ahr => ['usr'] }
1144 if ($hold and $hold->usr->id == $patron->id) {
1145 $self->checkout_is_for_hold(1);
1149 my $holdau = $hold->usr;
1152 $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1153 $payload->{patron_id} = $holdau->id;
1155 $payload->{patron_name} = "???";
1157 $payload->{hold_id} = $hold->id;
1158 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1159 payload => $payload));
1162 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1167 sub do_copy_checks {
1169 my $copy = $self->copy;
1170 return unless $copy;
1172 my $stat = $U->copy_status($copy->status)->id;
1174 # We cannot check out a copy if it is in-transit
1175 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1176 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1179 $self->handle_claims_returned();
1180 return if $self->bail_out;
1182 # no claims returned circ was found, check if there is any open circ
1183 unless( $self->is_renewal ) {
1185 my $circs = $self->editor->search_action_circulation(
1186 { target_copy => $copy->id, checkin_time => undef }
1189 if(my $old_circ = $circs->[0]) { # an open circ was found
1191 my $payload = {copy => $copy};
1193 if($old_circ->usr == $self->patron->id) {
1195 $payload->{old_circ} = $old_circ;
1197 # If there is an open circulation on the checkout item and an auto-renew
1198 # interval is defined, inform the caller that they should go
1199 # ahead and renew the item instead of warning about open circulations.
1201 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1203 'circ.checkout_auto_renew_age',
1207 if($auto_renew_intvl) {
1208 my $intvl_seconds = OpenILS::Utils::DateTime->interval_to_seconds($auto_renew_intvl);
1209 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( clean_ISO8601($old_circ->xact_start) );
1211 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1212 $payload->{auto_renew} = 1;
1217 return $self->bail_on_events(
1218 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1224 my $LEGACY_CIRC_EVENT_MAP = {
1225 'no_item' => 'ITEM_NOT_CATALOGED',
1226 'actor.usr.barred' => 'PATRON_BARRED',
1227 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1228 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1229 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1230 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1231 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1232 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1233 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1234 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1235 'config.circ_matrix_test.total_copy_hold_ratio' =>
1236 'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1237 'config.circ_matrix_test.available_copy_hold_ratio' =>
1238 'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1242 # ---------------------------------------------------------------------
1243 # This pushes any patron-related events into the list but does not
1244 # set bail_out for any events
1245 # ---------------------------------------------------------------------
1246 sub run_patron_permit_scripts {
1248 my $patronid = $self->patron->id;
1253 my $results = $self->run_indb_circ_test;
1254 unless($self->circ_test_success) {
1255 my @trimmed_results;
1257 if ($self->is_noncat) {
1258 # no_item result is OK during noncat checkout
1259 @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1263 if ($self->checkout_is_for_hold) {
1264 # if this checkout will fulfill a hold, ignore CIRC blocks
1265 # and rely instead on the (later-checked) FULFILL block
1267 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1268 my $fblock_pens = $self->editor->search_config_standing_penalty(
1269 {name => [@pen_names], block_list => {like => '%CIRC%'}});
1271 for my $res (@$results) {
1272 my $name = $res->{fail_part} || '';
1273 next if grep {$_->name eq $name} @$fblock_pens;
1274 push(@trimmed_results, $res);
1278 # not for hold or noncat
1279 @trimmed_results = @$results;
1283 # update the final set of test results
1284 $self->matrix_test_result(\@trimmed_results);
1286 push @allevents, $self->matrix_test_result_events;
1290 $_->{payload} = $self->copy if
1291 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1294 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1296 $self->push_events(@allevents);
1299 sub matrix_test_result_codes {
1301 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1304 sub matrix_test_result_events {
1307 my $event = new OpenILS::Event(
1308 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1310 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1312 } (@{$self->matrix_test_result});
1315 sub run_indb_circ_test {
1317 return $self->matrix_test_result if $self->matrix_test_result;
1319 my $dbfunc = ($self->is_renewal) ?
1320 'action.item_user_renew_test' : 'action.item_user_circ_test';
1322 if( $self->is_precat && $self->request_precat) {
1323 $self->make_precat_copy;
1324 return if $self->bail_out;
1327 my $results = $self->editor->json_query(
1331 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1337 $self->circ_test_success($U->is_true($results->[0]->{success}));
1339 if(my $mp = $results->[0]->{matchpoint}) {
1340 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1341 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1342 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1343 if(defined($results->[0]->{renewals})) {
1344 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1346 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1347 if(defined($results->[0]->{grace_period})) {
1348 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1350 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1351 if(defined($results->[0]->{hard_due_date})) {
1352 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1354 # Grab the *last* response for limit_groups, where it is more likely to be filled
1355 $self->limit_groups($results->[-1]->{limit_groups});
1358 return $self->matrix_test_result($results);
1361 # ---------------------------------------------------------------------
1362 # given a use and copy, this will calculate the circulation policy
1363 # parameters. Only works with in-db circ.
1364 # ---------------------------------------------------------------------
1368 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1370 $self->run_indb_circ_test;
1373 circ_test_success => $self->circ_test_success,
1374 failure_events => [],
1375 failure_codes => [],
1376 matchpoint => $self->circ_matrix_matchpoint
1379 unless($self->circ_test_success) {
1380 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1381 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1384 if($self->circ_matrix_matchpoint) {
1385 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1386 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1387 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1388 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1390 my $policy = $self->get_circ_policy(
1391 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1393 $$results{$_} = $$policy{$_} for keys %$policy;
1399 # ---------------------------------------------------------------------
1400 # Loads the circ policy info for duration, recurring fine, and max
1401 # fine based on the current copy
1402 # ---------------------------------------------------------------------
1403 sub get_circ_policy {
1404 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1407 duration_rule => $duration_rule->name,
1408 recurring_fine_rule => $recurring_fine_rule->name,
1409 max_fine_rule => $max_fine_rule->name,
1410 max_fine => $self->get_max_fine_amount($max_fine_rule),
1411 fine_interval => $recurring_fine_rule->recurrence_interval,
1412 renewal_remaining => $duration_rule->max_renewals,
1413 auto_renewal_remaining => $duration_rule->max_auto_renewals,
1414 grace_period => $recurring_fine_rule->grace_period
1417 if($hard_due_date) {
1418 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1419 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1422 $policy->{duration_date_ceiling} = undef;
1423 $policy->{duration_date_ceiling_force} = undef;
1426 $policy->{duration} = $duration_rule->shrt
1427 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1428 $policy->{duration} = $duration_rule->normal
1429 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1430 $policy->{duration} = $duration_rule->extended
1431 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1433 $policy->{recurring_fine} = $recurring_fine_rule->low
1434 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1435 $policy->{recurring_fine} = $recurring_fine_rule->normal
1436 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1437 $policy->{recurring_fine} = $recurring_fine_rule->high
1438 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1443 sub get_max_fine_amount {
1445 my $max_fine_rule = shift;
1446 my $max_amount = $max_fine_rule->amount;
1448 # if is_percent is true then the max->amount is
1449 # use as a percentage of the copy price
1450 if ($U->is_true($max_fine_rule->is_percent)) {
1451 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1452 $max_amount = $price * $max_fine_rule->amount / 100;
1454 $U->ou_ancestor_setting_value(
1456 'circ.max_fine.cap_at_price',
1460 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1461 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1469 sub run_copy_permit_scripts {
1471 my $copy = $self->copy || return;
1475 my $results = $self->run_indb_circ_test;
1476 push @allevents, $self->matrix_test_result_events
1477 unless $self->circ_test_success;
1479 # See if this copy has an alert message
1480 my $ae = $self->check_copy_alert();
1481 push( @allevents, $ae ) if $ae;
1483 # uniquify the events
1484 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1485 @allevents = values %hash;
1487 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1489 $self->push_events(@allevents);
1493 sub check_copy_alert {
1496 if ($self->new_copy_alerts) {
1498 push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts
1499 if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1501 push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts
1502 if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1505 $self->bail_out(1) if (!$self->override);
1506 return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1510 return undef if $self->is_renewal;
1511 return OpenILS::Event->new(
1512 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1513 if $self->copy and $self->copy->alert_message;
1519 # --------------------------------------------------------------------------
1520 # If the call is overriding and has permissions to override every collected
1521 # event, the are cleared. Any event that the caller does not have
1522 # permission to override, will be left in the event list and bail_out will
1524 # XXX We need code in here to cancel any holds/transits on copies
1525 # that are being force-checked out
1526 # --------------------------------------------------------------------------
1527 sub override_events {
1529 my @events = @{$self->events};
1530 return unless @events;
1531 my $oargs = $self->override_args;
1533 if(!$self->override) {
1534 return $self->bail_out(1)
1535 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1540 for my $e (@events) {
1541 my $tc = $e->{textcode};
1542 next if $tc eq 'SUCCESS';
1543 if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1544 my $ov = "$tc.override";
1545 $logger->info("circulator: attempting to override event: $ov");
1547 return $self->bail_on_events($self->editor->event)
1548 unless( $self->editor->allowed($ov) );
1550 return $self->bail_out(1);
1556 # --------------------------------------------------------------------------
1557 # If there is an open claimsreturn circ on the requested copy, close the
1558 # circ if overriding, otherwise bail out
1559 # --------------------------------------------------------------------------
1560 sub handle_claims_returned {
1562 my $copy = $self->copy;
1564 my $CR = $self->editor->search_action_circulation(
1566 target_copy => $copy->id,
1567 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1568 checkin_time => undef,
1572 return unless ($CR = $CR->[0]);
1576 # - If the caller has set the override flag, we will check the item in
1577 if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1579 $CR->checkin_time('now');
1580 $CR->checkin_scan_time('now');
1581 $CR->checkin_lib($self->circ_lib);
1582 $CR->checkin_workstation($self->editor->requestor->wsid);
1583 $CR->checkin_staff($self->editor->requestor->id);
1585 $evt = $self->editor->event
1586 unless $self->editor->update_action_circulation($CR);
1589 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1592 $self->bail_on_events($evt) if $evt;
1597 # --------------------------------------------------------------------------
1598 # This performs the checkout
1599 # --------------------------------------------------------------------------
1603 $self->log_me("do_checkout()");
1605 # make sure perms are good if this isn't a renewal
1606 unless( $self->is_renewal ) {
1607 return $self->bail_on_events($self->editor->event)
1608 unless( $self->editor->allowed('COPY_CHECKOUT') );
1611 # verify the permit key
1612 unless( $self->check_permit_key ) {
1613 if( $self->permit_override ) {
1614 return $self->bail_on_events($self->editor->event)
1615 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1617 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1621 # if this is a non-cataloged circ, build the circ and finish
1622 if( $self->is_noncat ) {
1623 $self->checkout_noncat;
1625 OpenILS::Event->new('SUCCESS',
1626 payload => { noncat_circ => $self->circ }));
1630 if( $self->is_precat ) {
1631 $self->make_precat_copy;
1632 return if $self->bail_out;
1634 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1635 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1638 $self->do_copy_checks;
1639 return if $self->bail_out;
1641 $self->run_checkout_scripts();
1642 return if $self->bail_out;
1644 $self->build_checkout_circ_object();
1645 return if $self->bail_out;
1647 my $modify_to_start = $self->booking_adjusted_due_date();
1648 return if $self->bail_out;
1650 $self->apply_modified_due_date($modify_to_start);
1651 return if $self->bail_out;
1653 return $self->bail_on_events($self->editor->event)
1654 unless $self->editor->create_action_circulation($self->circ);
1656 # refresh the circ to force local time zone for now
1657 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1659 if($self->limit_groups) {
1660 $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1663 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1665 return if $self->bail_out;
1667 $self->apply_deposit_fee();
1668 return if $self->bail_out;
1670 $self->handle_checkout_holds();
1671 return if $self->bail_out;
1673 # ------------------------------------------------------------------------------
1674 # Update the patron penalty info in the DB. Run it for permit-overrides
1675 # since the penalties are not updated during the permit phase
1676 # ------------------------------------------------------------------------------
1677 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1679 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1682 if($self->is_renewal) {
1683 # flesh the billing summary for the checked-in circ
1684 $pcirc = $self->editor->retrieve_action_circulation([
1686 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1691 OpenILS::Event->new('SUCCESS',
1693 copy => $U->unflesh_copy($self->copy),
1694 volume => $self->volume,
1695 circ => $self->circ,
1697 holds_fulfilled => $self->fulfilled_holds,
1698 deposit_billing => $self->deposit_billing,
1699 rental_billing => $self->rental_billing,
1700 parent_circ => $pcirc,
1701 patron => ($self->return_patron) ? $self->patron : undef,
1702 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1708 sub apply_deposit_fee {
1710 my $copy = $self->copy;
1712 ($self->is_deposit and not $self->is_deposit_exempt) or
1713 ($self->is_rental and not $self->is_rental_exempt);
1715 return if $self->is_deposit and $self->skip_deposit_fee;
1716 return if $self->is_rental and $self->skip_rental_fee;
1718 my $bill = Fieldmapper::money::billing->new;
1719 my $amount = $copy->deposit_amount;
1723 if($self->is_deposit) {
1724 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1726 $self->deposit_billing($bill);
1728 $billing_type = OILS_BILLING_TYPE_RENTAL;
1730 $self->rental_billing($bill);
1733 $bill->xact($self->circ->id);
1734 $bill->amount($amount);
1735 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1736 $bill->billing_type($billing_type);
1737 $bill->btype($btype);
1738 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1740 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1745 my $copy = $self->copy;
1747 my $stat = $copy->status if ref $copy->status;
1748 my $loc = $copy->location if ref $copy->location;
1749 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1751 $copy->status($stat->id) if $stat;
1752 $copy->location($loc->id) if $loc;
1753 $copy->circ_lib($circ_lib->id) if $circ_lib;
1754 $copy->editor($self->editor->requestor->id);
1755 $copy->edit_date('now');
1756 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1758 return $self->bail_on_events($self->editor->event)
1759 unless $self->editor->update_asset_copy($self->copy);
1761 $copy->status($U->copy_status($copy->status));
1762 $copy->location($loc) if $loc;
1763 $copy->circ_lib($circ_lib) if $circ_lib;
1766 sub update_reservation {
1768 my $reservation = $self->reservation;
1770 my $usr = $reservation->usr;
1771 my $target_rt = $reservation->target_resource_type;
1772 my $target_r = $reservation->target_resource;
1773 my $current_r = $reservation->current_resource;
1775 $reservation->usr($usr->id) if ref $usr;
1776 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1777 $reservation->target_resource($target_r->id) if ref $target_r;
1778 $reservation->current_resource($current_r->id) if ref $current_r;
1780 return $self->bail_on_events($self->editor->event)
1781 unless $self->editor->update_booking_reservation($self->reservation);
1784 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1785 $self->reservation($reservation);
1789 sub bail_on_events {
1790 my( $self, @evts ) = @_;
1791 $self->push_events(@evts);
1795 # ------------------------------------------------------------------------------
1796 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1797 # affects copies that will fulfill holds and CIRC affects all other copies.
1798 # If blocks exists, bail, push Events onto the event pile, and return true.
1799 # ------------------------------------------------------------------------------
1800 sub check_hold_fulfill_blocks {
1803 # With the addition of ignore_proximity in csp, we need to fetch
1804 # the proximity of both the circ_lib and the copy's circ_lib to
1805 # the patron's home_ou.
1806 my ($ou_prox, $copy_prox);
1807 my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1808 $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1809 $ou_prox = -1 unless (defined($ou_prox));
1810 my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1811 if ($copy_ou == $self->circ_lib) {
1812 # Save us the time of an extra query.
1813 $copy_prox = $ou_prox;
1815 $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1816 $copy_prox = -1 unless (defined($copy_prox));
1819 # See if the user has any penalties applied that prevent hold fulfillment
1820 my $pens = $self->editor->json_query({
1821 select => {csp => ['name', 'label']},
1822 from => {ausp => {csp => {}}},
1825 usr => $self->patron->id,
1826 org_unit => $U->get_org_full_path($self->circ_lib),
1828 {stop_date => undef},
1829 {stop_date => {'>' => 'now'}}
1833 block_list => {'like' => '%FULFILL%'},
1835 {ignore_proximity => undef},
1836 {ignore_proximity => {'<' => $ou_prox}},
1837 {ignore_proximity => {'<' => $copy_prox}}
1843 return 0 unless @$pens;
1845 for my $pen (@$pens) {
1846 $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1847 my $event = OpenILS::Event->new($pen->{name});
1848 $event->{desc} = $pen->{label};
1849 $self->push_events($event);
1852 $self->override_events;
1853 return $self->bail_out;
1857 # ------------------------------------------------------------------------------
1858 # When an item is checked out, see if we can fulfill a hold for this patron
1859 # ------------------------------------------------------------------------------
1860 sub handle_checkout_holds {
1862 my $copy = $self->copy;
1863 my $patron = $self->patron;
1865 my $e = $self->editor;
1866 $self->fulfilled_holds([]);
1868 # non-cats can't fulfill a hold
1869 return if $self->is_noncat;
1871 my $hold = $e->search_action_hold_request({
1872 current_copy => $copy->id ,
1873 cancel_time => undef,
1874 fulfillment_time => undef
1877 if($hold and $hold->usr != $patron->id) {
1878 # reset the hold since the copy is now checked out
1880 $logger->info("circulator: un-targeting hold ".$hold->id.
1881 " because copy ".$copy->id." is getting checked out");
1883 $hold->clear_prev_check_time;
1884 $hold->clear_current_copy;
1885 $hold->clear_capture_time;
1886 $hold->clear_shelf_time;
1887 $hold->clear_shelf_expire_time;
1888 $hold->clear_current_shelf_lib;
1890 return $self->bail_on_event($e->event)
1891 unless $e->update_action_hold_request($hold);
1897 $hold = $self->find_related_user_hold($copy, $patron) or return;
1898 $logger->info("circulator: found related hold to fulfill in checkout");
1901 return if $self->check_hold_fulfill_blocks;
1903 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1905 # if the hold was never officially captured, capture it.
1906 $hold->current_copy($copy->id);
1907 $hold->capture_time('now') unless $hold->capture_time;
1908 $hold->fulfillment_time('now');
1909 $hold->fulfillment_staff($e->requestor->id);
1910 $hold->fulfillment_lib($self->circ_lib);
1912 return $self->bail_on_events($e->event)
1913 unless $e->update_action_hold_request($hold);
1915 return $self->fulfilled_holds([$hold->id]);
1919 # ------------------------------------------------------------------------------
1920 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1921 # the patron directly targets the checked out item, see if there is another hold
1922 # for the patron that could be fulfilled by the checked out item. Fulfill the
1923 # oldest hold and only fulfill 1 of them.
1925 # For "another hold":
1927 # First, check for one that the copy matches via hold_copy_map, ensuring that
1928 # *any* hold type that this copy could fill may end up filled.
1930 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1931 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1932 # that are non-requestable to count as capturing those hold types.
1933 # ------------------------------------------------------------------------------
1934 sub find_related_user_hold {
1935 my($self, $copy, $patron) = @_;
1936 my $e = $self->editor;
1938 # holds on precat copies are always copy-level, so this call will
1939 # always return undef. Exit early.
1940 return undef if $self->is_precat;
1942 return undef unless $U->ou_ancestor_setting_value(
1943 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1945 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1947 select => {ahr => ['id']},
1956 fkey => 'current_copy',
1957 type => 'left' # there may be no current_copy
1964 fulfillment_time => undef,
1965 cancel_time => undef,
1967 {expire_time => undef},
1968 {expire_time => {'>' => 'now'}}
1972 target_copy => $self->copy->id
1976 {id => undef}, # left-join copy may be nonexistent
1977 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1981 order_by => {ahr => {request_time => {direction => 'asc'}}},
1985 my $hold_info = $e->json_query($args)->[0];
1986 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1987 return undef if $U->ou_ancestor_setting_value(
1988 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1990 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1992 select => {ahr => ['id']},
1997 fkey => 'current_copy',
1998 type => 'left' # there may be no current_copy
2005 fulfillment_time => undef,
2006 cancel_time => undef,
2008 {expire_time => undef},
2009 {expire_time => {'>' => 'now'}}
2016 target => $self->volume->id
2022 target => $self->title->id
2028 {id => undef}, # left-join copy may be nonexistent
2029 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2033 order_by => {ahr => {request_time => {direction => 'asc'}}},
2037 $hold_info = $e->json_query($args)->[0];
2038 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2043 sub run_checkout_scripts {
2056 my $hard_due_date_name;
2058 $self->run_indb_circ_test();
2059 $duration = $self->circ_matrix_matchpoint->duration_rule;
2060 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2061 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2062 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2064 $duration_name = $duration->name if $duration;
2065 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2068 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2069 return $self->bail_on_events($evt) if ($evt && !$nobail);
2071 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2072 return $self->bail_on_events($evt) if ($evt && !$nobail);
2074 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2075 return $self->bail_on_events($evt) if ($evt && !$nobail);
2077 if($hard_due_date_name) {
2078 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2079 return $self->bail_on_events($evt) if ($evt && !$nobail);
2085 # The item circulates with an unlimited duration
2089 $hard_due_date = undef;
2092 $self->duration_rule($duration);
2093 $self->recurring_fines_rule($recurring);
2094 $self->max_fine_rule($max_fine);
2095 $self->hard_due_date($hard_due_date);
2099 sub build_checkout_circ_object {
2102 my $circ = Fieldmapper::action::circulation->new;
2103 my $duration = $self->duration_rule;
2104 my $max = $self->max_fine_rule;
2105 my $recurring = $self->recurring_fines_rule;
2106 my $hard_due_date = $self->hard_due_date;
2107 my $copy = $self->copy;
2108 my $patron = $self->patron;
2109 my $duration_date_ceiling;
2110 my $duration_date_ceiling_force;
2114 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2115 $duration_date_ceiling = $policy->{duration_date_ceiling};
2116 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2118 my $dname = $duration->name;
2119 my $mname = $max->name;
2120 my $rname = $recurring->name;
2122 if($hard_due_date) {
2123 $hdname = $hard_due_date->name;
2126 $logger->debug("circulator: building circulation ".
2127 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2129 $circ->duration($policy->{duration});
2130 $circ->recurring_fine($policy->{recurring_fine});
2131 $circ->duration_rule($duration->name);
2132 $circ->recurring_fine_rule($recurring->name);
2133 $circ->max_fine_rule($max->name);
2134 $circ->max_fine($policy->{max_fine});
2135 $circ->fine_interval($recurring->recurrence_interval);
2136 $circ->renewal_remaining($duration->max_renewals);
2137 $circ->auto_renewal_remaining($duration->max_auto_renewals);
2138 $circ->grace_period($policy->{grace_period});
2142 $logger->info("circulator: copy found with an unlimited circ duration");
2143 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2144 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2145 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2146 $circ->renewal_remaining(0);
2147 $circ->grace_period(0);
2150 $circ->target_copy( $copy->id );
2151 $circ->usr( $patron->id );
2152 $circ->circ_lib( $self->circ_lib );
2153 $circ->workstation($self->editor->requestor->wsid)
2154 if defined $self->editor->requestor->wsid;
2156 # renewals maintain a link to the parent circulation
2157 $circ->parent_circ($self->parent_circ);
2159 if( $self->is_renewal ) {
2160 $circ->opac_renewal('t') if $self->opac_renewal;
2161 $circ->phone_renewal('t') if $self->phone_renewal;
2162 $circ->desk_renewal('t') if $self->desk_renewal;
2163 $circ->auto_renewal('t') if $self->auto_renewal;
2164 $circ->renewal_remaining($self->renewal_remaining);
2165 $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2166 $circ->circ_staff($self->editor->requestor->id);
2169 # if the user provided an overiding checkout time,
2170 # (e.g. the checkout really happened several hours ago), then
2171 # we apply that here. Does this need a perm??
2172 $circ->xact_start(clean_ISO8601($self->checkout_time))
2173 if $self->checkout_time;
2175 # if a patron is renewing, 'requestor' will be the patron
2176 $circ->circ_staff($self->editor->requestor->id);
2177 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2182 sub do_reservation_pickup {
2185 $self->log_me("do_reservation_pickup()");
2187 $self->reservation->pickup_time('now');
2190 $self->reservation->current_resource &&
2191 $U->is_true($self->reservation->target_resource_type->catalog_item)
2193 # We used to try to set $self->copy and $self->patron here,
2194 # but that should already be done.
2196 $self->run_checkout_scripts(1);
2198 my $duration = $self->duration_rule;
2199 my $max = $self->max_fine_rule;
2200 my $recurring = $self->recurring_fines_rule;
2202 if ($duration && $max && $recurring) {
2203 my $policy = $self->get_circ_policy($duration, $recurring, $max);
2205 my $dname = $duration->name;
2206 my $mname = $max->name;
2207 my $rname = $recurring->name;
2209 $logger->debug("circulator: updating reservation ".
2210 "with duration=$dname, maxfine=$mname, recurring=$rname");
2212 $self->reservation->fine_amount($policy->{recurring_fine});
2213 $self->reservation->max_fine($policy->{max_fine});
2214 $self->reservation->fine_interval($recurring->recurrence_interval);
2217 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2218 $self->update_copy();
2221 $self->reservation->fine_amount(
2222 $self->reservation->target_resource_type->fine_amount
2224 $self->reservation->max_fine(
2225 $self->reservation->target_resource_type->max_fine
2227 $self->reservation->fine_interval(
2228 $self->reservation->target_resource_type->fine_interval
2232 $self->update_reservation();
2235 sub do_reservation_return {
2237 my $request = shift;
2239 $self->log_me("do_reservation_return()");
2241 if (not ref $self->reservation) {
2242 my ($reservation, $evt) =
2243 $U->fetch_booking_reservation($self->reservation);
2244 return $self->bail_on_events($evt) if $evt;
2245 $self->reservation($reservation);
2248 $self->handle_fines(1);
2249 $self->reservation->return_time('now');
2250 $self->update_reservation();
2251 $self->reshelve_copy if $self->copy;
2253 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2254 $self->copy( $self->reservation->current_resource->catalog_item );
2258 sub booking_adjusted_due_date {
2260 my $circ = $self->circ;
2261 my $copy = $self->copy;
2263 return undef unless $self->use_booking;
2267 if( $self->due_date ) {
2269 return $self->bail_on_events($self->editor->event)
2270 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2272 $circ->due_date(clean_ISO8601($self->due_date));
2276 return unless $copy and $circ->due_date;
2279 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2280 if (@$booking_items) {
2281 my $booking_item = $booking_items->[0];
2282 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2284 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2285 my $shorten_circ_setting = $resource_type->elbow_room ||
2286 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2289 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2290 my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2291 resource => $booking_item->id
2292 , search_start => 'now'
2293 , search_end => $circ->due_date
2294 , fields => { cancel_time => undef, return_time => undef }
2296 $booking_ses->disconnect;
2298 throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2299 return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2301 my $dt_parser = DateTime::Format::ISO8601->new;
2302 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($circ->due_date) );
2304 for my $bid (@$bookings) {
2306 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2308 my $booking_start = $dt_parser->parse_datetime( clean_ISO8601($booking->start_time) );
2309 my $booking_end = $dt_parser->parse_datetime( clean_ISO8601($booking->end_time) );
2311 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2312 if ($booking_start < DateTime->now);
2315 if ($U->is_true($stop_circ_setting)) {
2316 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2318 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2319 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2322 # We set the circ duration here only to affect the logic that will
2323 # later (in a DB trigger) mangle the time part of the due date to
2324 # 11:59pm. Having any circ duration that is not a whole number of
2325 # days is enough to prevent the "correction."
2326 my $new_circ_duration = $due_date->epoch - time;
2327 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2328 $circ->duration("$new_circ_duration seconds");
2330 $circ->due_date(clean_ISO8601($due_date->strftime('%FT%T%z')));
2334 return $self->bail_on_events($self->editor->event)
2335 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2341 sub apply_modified_due_date {
2343 my $shift_earlier = shift;
2344 my $circ = $self->circ;
2345 my $copy = $self->copy;
2347 if( $self->due_date ) {
2349 return $self->bail_on_events($self->editor->event)
2350 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2352 $circ->due_date(clean_ISO8601($self->due_date));
2356 # if the due_date lands on a day when the location is closed
2357 return unless $copy and $circ->due_date;
2359 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2361 # due-date overlap should be determined by the location the item
2362 # is checked out from, not the owning or circ lib of the item
2363 my $org = $self->circ_lib;
2365 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2366 " with an item due date of ".$circ->due_date );
2368 my $dateinfo = $U->storagereq(
2369 'open-ils.storage.actor.org_unit.closed_date.overlap',
2370 $org, $circ->due_date );
2373 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2374 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2376 # XXX make the behavior more dynamic
2377 # for now, we just push the due date to after the close date
2378 if ($shift_earlier) {
2379 $circ->due_date($dateinfo->{start});
2381 $circ->due_date($dateinfo->{end});
2389 sub create_due_date {
2390 my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2392 # Look up circulating library's TZ, or else use client TZ, falling
2394 my $tz = $U->ou_ancestor_setting_value(
2400 my $due_date = $start_time ?
2401 DateTime::Format::ISO8601
2403 ->parse_datetime(clean_ISO8601($start_time))
2404 ->set_time_zone($tz) :
2405 DateTime->now(time_zone => $tz);
2407 # add the circ duration
2408 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($duration, $due_date));
2411 my $cdate = DateTime::Format::ISO8601
2413 ->parse_datetime(clean_ISO8601($date_ceiling))
2414 ->set_time_zone($tz);
2416 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2417 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2422 # return ISO8601 time with timezone
2423 return $due_date->strftime('%FT%T%z');
2428 sub make_precat_copy {
2430 my $copy = $self->copy;
2431 return $self->bail_on_events(OpenILS::Event->new('PERM_FAILURE'))
2432 unless $self->editor->allowed('CREATE_PRECAT') || $self->is_renewal;
2435 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2437 $copy->editor($self->editor->requestor->id);
2438 $copy->edit_date('now');
2439 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2440 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2441 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2442 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2443 $self->update_copy();
2447 $logger->info("circulator: Creating a new precataloged ".
2448 "copy in checkout with barcode " . $self->copy_barcode);
2450 $copy = Fieldmapper::asset::copy->new;
2451 $copy->circ_lib($self->circ_lib);
2452 $copy->creator($self->editor->requestor->id);
2453 $copy->editor($self->editor->requestor->id);
2454 $copy->barcode($self->copy_barcode);
2455 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2456 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2457 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2459 $copy->dummy_title($self->dummy_title || "");
2460 $copy->dummy_author($self->dummy_author || "");
2461 $copy->dummy_isbn($self->dummy_isbn || "");
2462 $copy->circ_modifier($self->circ_modifier);
2465 # See if we need to override the circ_lib for the copy with a configured circ_lib
2466 # Setting is shortname of the org unit
2467 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2468 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2470 if($precat_circ_lib) {
2471 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2474 $self->bail_on_events($self->editor->event);
2478 $copy->circ_lib($org->id);
2482 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2484 $self->push_events($self->editor->event);
2490 sub checkout_noncat {
2496 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2497 my $count = $self->noncat_count || 1;
2498 my $cotime = clean_ISO8601($self->checkout_time) || "";
2500 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2504 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2505 $self->editor->requestor->id,
2513 $self->push_events($evt);
2521 # if an item is in transit but the status doesn't agree, then we need to fix things.
2522 # The next two subs will hopefully do that
2523 sub fix_broken_transit_status {
2526 # Capture the transit so we don't have to fetch it again later during checkin
2527 # This used to live in sub check_transit_checkin_interval and later again in
2530 $self->editor->search_action_transit_copy(
2531 {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2535 if ($self->transit && $U->copy_status($self->copy->status)->id != OILS_COPY_STATUS_IN_TRANSIT) {
2536 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2537 " that is in-transit but without the In Transit status... fixing");
2538 $self->copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2539 # FIXME - do we want to make this permanent if the checkin bails?
2544 sub cancel_transit_if_circ_exists {
2546 if ($self->circ && $self->transit) {
2547 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2548 " that is in-transit AND circulating... aborting the transit");
2549 my $circ_ses = create OpenSRF::AppSession("open-ils.circ");
2550 my $result = $circ_ses->request(
2551 "open-ils.circ.transit.abort",
2552 $self->editor->authtoken,
2553 { 'transitid' => $self->transit->id }
2555 $logger->warn("circulator: transit abort result: ".$result);
2556 $circ_ses->disconnect;
2557 $self->transit(undef);
2561 # If a copy goes into transit and is then checked in before the transit checkin
2562 # interval has expired, push an event onto the overridable events list.
2563 sub check_transit_checkin_interval {
2566 # only concerned with in-transit items
2567 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2569 # no interval, no problem
2570 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2571 return unless $interval;
2573 # transit from X to X for whatever reason has no min interval
2574 return if $self->transit->source == $self->transit->dest;
2576 my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2577 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2578 my $horizon = $t_start->add(seconds => $seconds);
2580 # See if we are still within the transit checkin forbidden range
2581 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2582 if $horizon > DateTime->now;
2585 # Retarget local holds at checkin
2586 sub checkin_retarget {
2588 return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2589 return unless $self->is_checkin; # Renewals need not be checked
2590 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2591 return if $self->is_precat; # No holds for precats
2592 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2593 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2594 my $status = $U->copy_status($self->copy->status);
2595 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2596 # Specifically target items that are likely new (by status ID)
2597 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2598 my $location = $self->copy->location;
2599 if(!ref($location)) {
2600 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2601 $self->copy->location($location);
2603 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2605 # Fetch holds for the bib
2606 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2607 $self->editor->authtoken,
2610 capture_time => undef, # No touching captured holds
2611 frozen => 'f', # Don't bother with frozen holds
2612 pickup_lib => $self->circ_lib # Only holds actually here
2615 # Error? Skip the step.
2616 return if exists $result->{"ilsevent"};
2620 foreach my $holdlist (keys %{$result}) {
2621 push @$holds, @{$result->{$holdlist}};
2624 return if scalar(@$holds) == 0; # No holds, no retargeting
2626 # Check for parts on this copy
2627 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2628 my %parts_hash = ();
2629 %parts_hash = map {$_->part, 1} @$parts if @$parts;
2631 # Loop over holds in request-ish order
2632 # Stage 1: Get them into request-ish order
2633 # Also grab type and target for skipping low hanging ones
2634 $result = $self->editor->json_query({
2635 "select" => { "ahr" => ["id", "hold_type", "target"] },
2636 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2637 "where" => { "id" => $holds },
2639 { "class" => "pgt", "field" => "hold_priority"},
2640 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2641 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2642 { "class" => "ahr", "field" => "request_time"}
2647 if (ref $result eq "ARRAY" and scalar @$result) {
2648 foreach (@{$result}) {
2649 # Copy level, but not this copy?
2650 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2651 and $_->{target} != $self->copy->id);
2652 # Volume level, but not this volume?
2653 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2654 if(@$parts) { # We have parts?
2656 next if ($_->{hold_type} eq 'T');
2657 # Skip part holds for parts not on this copy
2658 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2660 # No parts, no part holds
2661 next if ($_->{hold_type} eq 'P');
2663 # So much for easy stuff, attempt a retarget!
2664 my $tresult = $U->simplereq(
2665 'open-ils.hold-targeter',
2666 'open-ils.hold-targeter.target',
2667 {hold => $_->{id}, find_copy => $self->copy->id}
2669 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2670 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2678 $self->log_me("do_checkin()");
2680 return $self->bail_on_events(
2681 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2684 $self->fix_broken_transit_status; # if applicable
2685 $self->check_transit_checkin_interval;
2686 $self->checkin_retarget;
2688 # the renew code and mk_env should have already found our circulation object
2689 unless( $self->circ ) {
2691 my $circs = $self->editor->search_action_circulation(
2692 { target_copy => $self->copy->id, checkin_time => undef });
2694 $self->circ($$circs[0]);
2696 # for now, just warn if there are multiple open circs on a copy
2697 $logger->warn("circulator: we have ".scalar(@$circs).
2698 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2700 $self->cancel_transit_if_circ_exists; # if applicable
2702 my $stat = $U->copy_status($self->copy->status)->id;
2704 # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2705 # differently if they are already paid for. We need to check for this
2706 # early since overdue generation is potentially affected.
2707 my $dont_change_lost_zero = 0;
2708 if ($stat == OILS_COPY_STATUS_LOST
2709 || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2710 || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2712 # LOST fine settings are controlled by the copy's circ lib, not the the
2714 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2715 $self->copy->circ_lib->id : $self->copy->circ_lib;
2716 $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2717 $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2718 $self->editor) || 0;
2720 # Don't assume there's always a circ based on copy status
2721 if ($dont_change_lost_zero && $self->circ) {
2722 my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2723 $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2726 $self->dont_change_lost_zero($dont_change_lost_zero);
2729 my $latest_inventory = Fieldmapper::asset::latest_inventory->new;
2731 if ($self->do_inventory_update) {
2732 $latest_inventory->inventory_date('now');
2733 $latest_inventory->inventory_workstation($self->editor->requestor->wsid);
2734 $latest_inventory->copy($self->copy->id());
2736 my $alci = $self->editor->search_asset_latest_inventory(
2737 {copy => $self->copy->id}
2739 $latest_inventory = $alci->[0]
2741 $self->latest_inventory($latest_inventory);
2743 if( $self->checkin_check_holds_shelf() ) {
2744 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2745 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2746 if($self->fake_hold_dest) {
2747 $self->hold->pickup_lib($self->circ_lib);
2749 $self->checkin_flesh_events;
2753 unless( $self->is_renewal ) {
2754 return $self->bail_on_events($self->editor->event)
2755 unless $self->editor->allowed('COPY_CHECKIN');
2758 $self->push_events($self->check_copy_alert());
2759 $self->push_events($self->check_checkin_copy_status());
2761 # if the circ is marked as 'claims returned', add the event to the list
2762 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2763 if ($self->circ and $self->circ->stop_fines
2764 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2766 $self->check_circ_deposit();
2768 # handle the overridable events
2769 $self->override_events unless $self->is_renewal;
2770 return if $self->bail_out;
2773 $self->checkin_handle_circ_start;
2774 return if $self->bail_out;
2776 if (!$dont_change_lost_zero) {
2777 # if this circ is LOST and we are configured to generate overdue
2778 # fines for lost items on checkin (to fill the gap between mark
2779 # lost time and when the fines would have naturally stopped), then
2780 # stop_fines is no longer valid and should be cleared.
2782 # stop_fines will be set again during the handle_fines() stage.
2783 # XXX should this setting come from the copy circ lib (like other
2784 # LOST settings), instead of the circulation circ lib?
2785 if ($stat == OILS_COPY_STATUS_LOST) {
2786 $self->circ->clear_stop_fines if
2787 $U->ou_ancestor_setting_value(
2789 OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2794 # Set stop_fines when claimed never checked out
2795 $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2797 # handle fines for this circ, including overdue gen if needed
2798 $self->handle_fines;
2801 $self->checkin_handle_circ_finish;
2802 return if $self->bail_out;
2803 $self->checkin_changed(1);
2805 } elsif( $self->transit ) {
2806 my $hold_transit = $self->process_received_transit;
2807 $self->checkin_changed(1);
2809 if( $self->bail_out ) {
2810 $self->checkin_flesh_events;
2814 if( my $e = $self->check_checkin_copy_status() ) {
2815 # If the original copy status is special, alert the caller
2816 my $ev = $self->events;
2817 $self->events([$e]);
2818 $self->override_events;
2819 return if $self->bail_out;
2823 if( $hold_transit or
2824 $U->copy_status($self->copy->status)->id
2825 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2828 if( $hold_transit ) {
2829 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2831 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2836 if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2838 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2839 $self->reshelve_copy(1);
2840 $self->cancelled_hold_transit(1);
2841 $self->notify_hold(0); # don't notify for cancelled holds
2842 $self->fake_hold_dest(0);
2843 return if $self->bail_out;
2845 } elsif ($hold and $hold->hold_type eq 'R') {
2847 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2848 $self->notify_hold(0); # No need to notify
2849 $self->fake_hold_dest(0);
2850 $self->noop(1); # Don't try and capture for other holds/transits now
2851 $self->update_copy();
2852 $hold->fulfillment_time('now');
2853 $self->bail_on_events($self->editor->event)
2854 unless $self->editor->update_action_hold_request($hold);
2858 # hold transited to correct location
2859 if($self->fake_hold_dest) {
2860 $hold->pickup_lib($self->circ_lib);
2862 $self->checkin_flesh_events;
2867 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2869 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2870 " that is in-transit, but there is no transit.. repairing");
2871 $self->reshelve_copy(1);
2872 return if $self->bail_out;
2875 if( $self->is_renewal ) {
2876 $self->finish_fines_and_voiding;
2877 return if $self->bail_out;
2878 $self->push_events(OpenILS::Event->new('SUCCESS'));
2882 # ------------------------------------------------------------------------------
2883 # Circulations and transits are now closed where necessary. Now go on to see if
2884 # this copy can fulfill a hold or needs to be routed to a different location
2885 # ------------------------------------------------------------------------------
2887 my $needed_for_something = 0; # formerly "needed_for_hold"
2889 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2891 if (!$self->remote_hold) {
2892 if ($self->use_booking) {
2893 my $potential_hold = $self->hold_capture_is_possible;
2894 my $potential_reservation = $self->reservation_capture_is_possible;
2896 if ($potential_hold and $potential_reservation) {
2897 $logger->info("circulator: item could fulfill either hold or reservation");
2898 $self->push_events(new OpenILS::Event(
2899 "HOLD_RESERVATION_CONFLICT",
2900 "hold" => $potential_hold,
2901 "reservation" => $potential_reservation
2903 return if $self->bail_out;
2904 } elsif ($potential_hold) {
2905 $needed_for_something =
2906 $self->attempt_checkin_hold_capture;
2907 } elsif ($potential_reservation) {
2908 $needed_for_something =
2909 $self->attempt_checkin_reservation_capture;
2912 $needed_for_something = $self->attempt_checkin_hold_capture;
2915 return if $self->bail_out;
2917 unless($needed_for_something) {
2918 my $circ_lib = (ref $self->copy->circ_lib) ?
2919 $self->copy->circ_lib->id : $self->copy->circ_lib;
2921 if( $self->remote_hold ) {
2922 $circ_lib = $self->remote_hold->pickup_lib;
2923 $logger->warn("circulator: Copy ".$self->copy->barcode.
2924 " is on a remote hold's shelf, sending to $circ_lib");
2927 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2929 my $suppress_transit = 0;
2931 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2932 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2933 if($suppress_transit_source && $suppress_transit_source->{value}) {
2934 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2935 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2936 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2937 $suppress_transit = 1;
2942 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2943 # copy is where it needs to be, either for hold or reshelving
2945 $self->checkin_handle_precat();
2946 return if $self->bail_out;
2949 # copy needs to transit "home", or stick here if it's a floating copy
2951 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2952 my $res = $self->editor->json_query(
2954 'evergreen.can_float',
2955 $self->copy->floating->id,
2956 $self->copy->circ_lib,
2961 $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res;
2963 if ($can_float) { # Yep, floating, stick here
2964 $self->checkin_changed(1);
2965 $self->copy->circ_lib( $self->circ_lib );
2968 my $bc = $self->copy->barcode;
2969 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2970 $self->checkin_build_copy_transit($circ_lib);
2971 return if $self->bail_out;
2972 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2976 } else { # no-op checkin
2977 if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2978 my $res = $self->editor->json_query(
2981 'evergreen.can_float',
2982 $self->copy->floating->id,
2983 $self->copy->circ_lib,
2988 if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2989 $self->checkin_changed(1);
2990 $self->copy->circ_lib( $self->circ_lib );
2996 if($self->claims_never_checked_out and
2997 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2999 # the item was not supposed to be checked out to the user and should now be marked as missing
3000 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
3001 $self->copy->status($next_status);
3005 $self->reshelve_copy unless $needed_for_something;
3008 return if $self->bail_out;
3010 unless($self->checkin_changed) {
3012 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
3013 my $stat = $U->copy_status($self->copy->status)->id;
3015 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
3016 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
3017 $self->bail_out(1); # no need to commit anything
3021 $self->push_events(OpenILS::Event->new('SUCCESS'))
3022 unless @{$self->events};
3025 $self->finish_fines_and_voiding;
3027 OpenILS::Utils::Penalty->calculate_penalties(
3028 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3030 $self->checkin_flesh_events;
3034 sub finish_fines_and_voiding {
3036 return unless $self->circ;
3038 return unless $self->backdate or $self->void_overdues;
3040 # void overdues after fine generation to prevent concurrent DB access to overdue billings
3041 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3043 my $evt = $CC->void_or_zero_overdues(
3044 $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3046 return $self->bail_on_events($evt) if $evt;
3048 # Make sure the circ is open or closed as necessary.
3049 $evt = $U->check_open_xact($self->editor, $self->circ->id);
3050 return $self->bail_on_events($evt) if $evt;
3056 # if a deposit was payed for this item, push the event
3057 sub check_circ_deposit {
3059 return unless $self->circ;
3060 my $deposit = $self->editor->search_money_billing(
3062 xact => $self->circ->id,
3064 }, {idlist => 1})->[0];
3066 $self->push_events(OpenILS::Event->new(
3067 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
3072 my $force = $self->force || shift;
3073 my $copy = $self->copy;
3075 my $stat = $U->copy_status($copy->status)->id;
3077 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3080 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3081 $stat != OILS_COPY_STATUS_CATALOGING and
3082 $stat != OILS_COPY_STATUS_IN_TRANSIT and
3083 $stat != $next_status )) {
3085 $copy->status( $next_status );
3087 $self->checkin_changed(1);
3092 # Returns true if the item is at the current location
3093 # because it was transited there for a hold and the
3094 # hold has not been fulfilled
3095 sub checkin_check_holds_shelf {
3097 return 0 unless $self->copy;
3100 $U->copy_status($self->copy->status)->id ==
3101 OILS_COPY_STATUS_ON_HOLDS_SHELF;
3103 # Attempt to clear shelf expired holds for this copy
3104 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3105 if($self->clear_expired);
3107 # find the hold that put us on the holds shelf
3108 my $holds = $self->editor->search_action_hold_request(
3110 current_copy => $self->copy->id,
3111 capture_time => { '!=' => undef },
3112 fulfillment_time => undef,
3113 cancel_time => undef,
3118 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3119 $self->reshelve_copy(1);
3123 my $hold = $$holds[0];
3125 $logger->info("circulator: we found a captured, un-fulfilled hold [".
3126 $hold->id. "] for copy ".$self->copy->barcode);
3128 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3129 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3130 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3131 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3132 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3133 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3134 $self->fake_hold_dest(1);
3140 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3141 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3145 $logger->info("circulator: hold is not for here..");
3146 $self->remote_hold($hold);
3151 sub checkin_handle_precat {
3153 my $copy = $self->copy;
3155 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3156 $copy->status(OILS_COPY_STATUS_CATALOGING);
3157 $self->update_copy();
3158 $self->checkin_changed(1);
3159 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3164 sub checkin_build_copy_transit {
3167 my $copy = $self->copy;
3168 my $transit = Fieldmapper::action::transit_copy->new;
3170 # if we are transiting an item to the shelf shelf, it's a hold transit
3171 if (my $hold = $self->remote_hold) {
3172 $transit = Fieldmapper::action::hold_transit_copy->new;
3173 $transit->hold($hold->id);
3175 # the item is going into transit, remove any shelf-iness
3176 if ($hold->current_shelf_lib or $hold->shelf_time) {
3177 $hold->clear_current_shelf_lib;
3178 $hold->clear_shelf_time;
3179 return $self->bail_on_events($self->editor->event)
3180 unless $self->editor->update_action_hold_request($hold);
3184 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3185 $logger->info("circulator: transiting copy to $dest");
3187 $transit->source($self->circ_lib);
3188 $transit->dest($dest);
3189 $transit->target_copy($copy->id);
3190 $transit->source_send_time('now');
3191 $transit->copy_status( $U->copy_status($copy->status)->id );
3193 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3195 if ($self->remote_hold) {
3196 return $self->bail_on_events($self->editor->event)
3197 unless $self->editor->create_action_hold_transit_copy($transit);
3199 return $self->bail_on_events($self->editor->event)
3200 unless $self->editor->create_action_transit_copy($transit);
3203 # ensure the transit is returned to the caller
3204 $self->transit($transit);
3206 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3208 $self->checkin_changed(1);
3212 sub hold_capture_is_possible {
3214 my $copy = $self->copy;
3216 # we've been explicitly told not to capture any holds
3217 return 0 if $self->capture eq 'nocapture';
3219 # See if this copy can fulfill any holds
3220 my $hold = $holdcode->find_nearest_permitted_hold(
3221 $self->editor, $copy, $self->editor->requestor, 1 # check_only
3223 return undef if ref $hold eq "HASH" and
3224 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3228 sub reservation_capture_is_possible {
3230 my $copy = $self->copy;
3232 # we've been explicitly told not to capture any holds
3233 return 0 if $self->capture eq 'nocapture';
3235 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3236 my $resv = $booking_ses->request(
3237 "open-ils.booking.reservations.could_capture",
3238 $self->editor->authtoken, $copy->barcode
3240 $booking_ses->disconnect;
3241 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3242 $self->push_events($resv);
3248 # returns true if the item was used (or may potentially be used
3249 # in subsequent calls) to capture a hold.
3250 sub attempt_checkin_hold_capture {
3252 my $copy = $self->copy;
3254 # we've been explicitly told not to capture any holds
3255 return 0 if $self->capture eq 'nocapture';
3257 # See if this copy can fulfill any holds
3258 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3259 $self->editor, $copy, $self->editor->requestor );
3262 $logger->debug("circulator: no potential permitted".
3263 "holds found for copy ".$copy->barcode);
3267 if($self->capture ne 'capture') {
3268 # see if this item is in a hold-capture-delay location
3269 my $location = $self->copy->location;
3270 if(!ref($location)) {
3271 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3272 $self->copy->location($location);
3274 if($U->is_true($location->hold_verify)) {
3275 $self->bail_on_events(
3276 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3281 $self->retarget($retarget);
3283 my $suppress_transit = 0;
3284 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3285 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3286 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3287 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3288 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3289 $suppress_transit = 1;
3290 $hold->pickup_lib($self->circ_lib);
3295 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3297 $hold->current_copy($copy->id);
3298 $hold->capture_time('now');
3299 $self->put_hold_on_shelf($hold)
3300 if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3302 # prevent DB errors caused by fetching
3303 # holds from storage, and updating through cstore
3304 $hold->clear_fulfillment_time;
3305 $hold->clear_fulfillment_staff;
3306 $hold->clear_fulfillment_lib;
3307 $hold->clear_expire_time;
3308 $hold->clear_cancel_time;
3309 $hold->clear_prev_check_time unless $hold->prev_check_time;
3311 $self->bail_on_events($self->editor->event)
3312 unless $self->editor->update_action_hold_request($hold);
3314 $self->checkin_changed(1);
3316 return 0 if $self->bail_out;
3318 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3320 if ($hold->hold_type eq 'R') {
3321 $copy->status(OILS_COPY_STATUS_CATALOGING);
3322 $hold->fulfillment_time('now');
3323 $self->noop(1); # Block other transit/hold checks
3324 $self->bail_on_events($self->editor->event)
3325 unless $self->editor->update_action_hold_request($hold);
3327 # This hold was captured in the correct location
3328 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3329 $self->push_events(OpenILS::Event->new('SUCCESS'));
3331 #$self->do_hold_notify($hold->id);
3332 $self->notify_hold($hold->id);
3337 # Hold needs to be picked up elsewhere. Build a hold
3338 # transit and route the item.
3339 $self->checkin_build_hold_transit();
3340 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3341 return 0 if $self->bail_out;
3342 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3345 # make sure we save the copy status
3347 return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3351 sub attempt_checkin_reservation_capture {
3353 my $copy = $self->copy;
3355 # we've been explicitly told not to capture any holds
3356 return 0 if $self->capture eq 'nocapture';
3358 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3359 my $evt = $booking_ses->request(
3360 "open-ils.booking.resources.capture_for_reservation",
3361 $self->editor->authtoken,
3363 1 # don't update copy - we probably have it locked
3365 $booking_ses->disconnect;
3367 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3369 "open-ils.booking.resources.capture_for_reservation " .
3370 "didn't return an event!"
3374 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3375 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3377 # not-transferable is an error event we'll pass on the user
3378 $logger->warn("reservation capture attempted against non-transferable item");
3379 $self->push_events($evt);
3381 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3382 # Re-retrieve copy as reservation capture may have changed
3383 # its status and whatnot.
3385 "circulator: booking capture win on copy " . $self->copy->id
3387 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3389 "circulator: changing copy " . $self->copy->id .
3390 "'s status from " . $self->copy->status . " to " .
3393 $self->copy->status($new_copy_status);
3396 $self->reservation($evt->{"payload"}->{"reservation"});
3398 if (exists $evt->{"payload"}->{"transit"}) {
3402 "org" => $evt->{"payload"}->{"transit"}->dest
3406 $self->checkin_changed(1);
3410 # other results are treated as "nothing to capture"
3414 sub do_hold_notify {
3415 my( $self, $holdid ) = @_;
3417 my $e = new_editor(xact => 1);
3418 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3420 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3421 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3423 $logger->info("circulator: running delayed hold notify process");
3425 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3426 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3428 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3429 hold_id => $holdid, requestor => $self->editor->requestor);
3431 $logger->debug("circulator: built hold notifier");
3433 if(!$notifier->event) {
3435 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3437 my $stat = $notifier->send_email_notify;
3438 if( $stat == '1' ) {
3439 $logger->info("circulator: hold notify succeeded for hold $holdid");
3443 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3446 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3450 sub retarget_holds {
3452 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3453 my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3454 $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3455 # no reason to wait for the return value
3459 sub checkin_build_hold_transit {
3462 my $copy = $self->copy;
3463 my $hold = $self->hold;
3464 my $trans = Fieldmapper::action::hold_transit_copy->new;
3466 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3468 $trans->hold($hold->id);
3469 $trans->source($self->circ_lib);
3470 $trans->dest($hold->pickup_lib);
3471 $trans->source_send_time("now");
3472 $trans->target_copy($copy->id);
3474 # when the copy gets to its destination, it will recover
3475 # this status - put it onto the holds shelf
3476 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3478 return $self->bail_on_events($self->editor->event)
3479 unless $self->editor->create_action_hold_transit_copy($trans);
3484 sub process_received_transit {
3486 my $copy = $self->copy;
3487 my $copyid = $self->copy->id;
3489 my $status_name = $U->copy_status($copy->status)->name;
3490 $logger->debug("circulator: attempting transit receive on ".
3491 "copy $copyid. Copy status is $status_name");
3493 my $transit = $self->transit;
3495 # Check if we are in a transit suppress range
3496 my $suppress_transit = 0;
3497 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3498 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3499 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3500 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3501 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3502 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3503 $suppress_transit = 1;
3504 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3508 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3509 # - this item is in-transit to a different location
3510 # - Or we are capturing holds as transits, so why create a new transit?
3512 my $tid = $transit->id;
3513 my $loc = $self->circ_lib;
3514 my $dest = $transit->dest;
3516 $logger->info("circulator: Fowarding transit on copy which is destined ".
3517 "for a different location. transit=$tid, copy=$copyid, current ".
3518 "location=$loc, destination location=$dest");
3520 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3522 # grab the associated hold object if available
3523 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3524 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3526 return $self->bail_on_events($evt);
3529 # The transit is received, set the receive time
3530 $transit->dest_recv_time('now');
3531 $self->bail_on_events($self->editor->event)
3532 unless $self->editor->update_action_transit_copy($transit);
3534 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3536 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3537 $copy->status( $transit->copy_status );
3538 $self->update_copy();
3539 return if $self->bail_out;
3543 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3546 # hold has arrived at destination, set shelf time
3547 $self->put_hold_on_shelf($hold);
3548 $self->bail_on_events($self->editor->event)
3549 unless $self->editor->update_action_hold_request($hold);
3550 return if $self->bail_out;
3552 $self->notify_hold($hold_transit->hold);
3555 $hold_transit = undef;
3556 $self->cancelled_hold_transit(1);
3557 $self->reshelve_copy(1);
3558 $self->fake_hold_dest(0);
3563 OpenILS::Event->new(
3566 payload => { transit => $transit, holdtransit => $hold_transit } ));
3568 return $hold_transit;
3572 # ------------------------------------------------------------------
3573 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3574 # ------------------------------------------------------------------
3575 sub put_hold_on_shelf {
3576 my($self, $hold) = @_;
3577 $hold->shelf_time('now');
3578 $hold->current_shelf_lib($self->circ_lib);
3579 $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3585 my $reservation = shift;
3586 my $dt_parser = DateTime::Format::ISO8601->new;
3588 my $obj = $reservation ? $self->reservation : $self->circ;
3590 my $lost_bill_opts = $self->lost_bill_options;
3591 my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3592 # first, restore any voided overdues for lost, if needed
3593 if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3594 my $restore_od = $U->ou_ancestor_setting_value(
3595 $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3596 $self->editor) || 0;
3597 $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3601 # next, handle normal overdue generation and apply stop_fines
3602 # XXX reservations don't have stop_fines
3603 # TODO revisit booking_reservation re: stop_fines support
3604 if ($reservation or !$obj->stop_fines) {
3607 # This is a crude check for whether we are in a grace period. The code
3608 # in generate_fines() does a more thorough job, so this exists solely
3609 # as a small optimization, and might be better off removed.
3611 # If we have a grace period
3612 if($obj->can('grace_period')) {
3613 # Parse out the due date
3614 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($obj->due_date) );
3615 # Add the grace period to the due date
3616 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($obj->grace_period));
3617 # Don't generate fines on circs still in grace period
3618 $skip_for_grace = $due_date > DateTime->now;
3620 $CC->generate_fines({circs => [$obj], editor => $self->editor})
3621 unless $skip_for_grace;
3623 if (!$reservation and !$obj->stop_fines) {
3624 $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3625 $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3626 $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3627 $obj->stop_fines_time('now');
3628 $obj->stop_fines_time($self->backdate) if $self->backdate;
3629 $self->editor->update_action_circulation($obj);
3633 # finally, handle voiding of lost item and processing fees
3634 if ($self->needs_lost_bill_handling) {
3635 my $void_cost = $U->ou_ancestor_setting_value(
3636 $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3637 $self->editor) || 0;
3638 my $void_proc_fee = $U->ou_ancestor_setting_value(
3639 $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3640 $self->editor) || 0;
3641 $self->checkin_handle_lost_or_lo_now_found(
3642 $lost_bill_opts->{void_cost_btype},
3643 $lost_bill_opts->{is_longoverdue}) if $void_cost;
3644 $self->checkin_handle_lost_or_lo_now_found(
3645 $lost_bill_opts->{void_fee_btype},
3646 $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3652 sub checkin_handle_circ_start {
3654 my $circ = $self->circ;
3655 my $copy = $self->copy;
3659 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3661 # backdate the circ if necessary
3662 if($self->backdate) {
3663 my $evt = $self->checkin_handle_backdate;
3664 return $self->bail_on_events($evt) if $evt;
3667 # Set the checkin vars since we have the item
3668 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3670 # capture the true scan time for back-dated checkins
3671 $circ->checkin_scan_time('now');
3673 $circ->checkin_staff($self->editor->requestor->id);
3674 $circ->checkin_lib($self->circ_lib);
3675 $circ->checkin_workstation($self->editor->requestor->wsid);
3677 my $circ_lib = (ref $self->copy->circ_lib) ?
3678 $self->copy->circ_lib->id : $self->copy->circ_lib;
3679 my $stat = $U->copy_status($self->copy->status)->id;
3681 if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3682 # we will now handle lost fines, but the copy will retain its 'lost'
3683 # status if it needs to transit home unless lost_immediately_available
3686 # if we decide to also delay fine handling until the item arrives home,
3687 # we will need to call lost fine handling code both when checking items
3688 # in and also when receiving transits
3689 $self->checkin_handle_lost($circ_lib);
3690 } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3691 # same process as above.
3692 $self->checkin_handle_long_overdue($circ_lib);
3693 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3694 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3696 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3697 $self->copy->status($U->copy_status($next_status));
3704 sub checkin_handle_circ_finish {
3706 my $e = $self->editor;
3707 my $circ = $self->circ;
3709 # Do one last check before the final circulation update to see
3710 # if the xact_finish value should be set or not.
3712 # The underlying money.billable_xact may have been updated to
3713 # reflect a change in xact_finish during checkin bills handling,
3714 # however we can't simply refresh the circulation from the DB,
3715 # because other changes may be pending. Instead, reproduce the
3716 # xact_finish check here. It won't hurt to do it again.
3718 my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3719 if ($sum) { # is this test still needed?
3721 my $balance = $sum->balance_owed;
3723 if ($balance == 0) {
3724 $circ->xact_finish('now');
3726 $circ->clear_xact_finish;
3729 $logger->info("circulator: $balance is owed on this circulation");
3732 return $self->bail_on_events($e->event)
3733 unless $e->update_action_circulation($circ);
3738 # ------------------------------------------------------------------
3739 # See if we need to void billings, etc. for lost checkin
3740 # ------------------------------------------------------------------
3741 sub checkin_handle_lost {
3743 my $circ_lib = shift;
3745 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3746 OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3748 $self->lost_bill_options({
3749 circ_lib => $circ_lib,
3750 ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3751 ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3752 ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3753 void_cost_btype => 3,
3757 return $self->checkin_handle_lost_or_longoverdue(
3758 circ_lib => $circ_lib,
3759 max_return => $max_return,
3760 ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3761 ous_use_last_activity => undef # not supported for LOST checkin
3765 # ------------------------------------------------------------------
3766 # See if we need to void billings, etc. for long-overdue checkin
3767 # note: not using constants below since they serve little purpose
3768 # for single-use strings that are descriptive in their own right
3769 # and mostly just complicate debugging.
3770 # ------------------------------------------------------------------
3771 sub checkin_handle_long_overdue {
3773 my $circ_lib = shift;
3775 $logger->info("circulator: processing long-overdue checkin...");
3777 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3778 'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3780 $self->lost_bill_options({
3781 circ_lib => $circ_lib,
3782 ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3783 ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3784 is_longoverdue => 1,
3785 ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3786 void_cost_btype => 10,
3787 void_fee_btype => 11
3790 return $self->checkin_handle_lost_or_longoverdue(
3791 circ_lib => $circ_lib,
3792 max_return => $max_return,
3793 ous_immediately_available => 'circ.longoverdue_immediately_available',
3794 ous_use_last_activity =>
3795 'circ.longoverdue.use_last_activity_date_on_return'
3799 # last billing activity is last payment time, last billing time, or the
3800 # circ due date. If the relevant "use last activity" org unit setting is
3801 # false/unset, then last billing activity is always the due date.
3802 sub get_circ_last_billing_activity {
3804 my $circ_lib = shift;
3805 my $setting = shift;
3806 my $date = $self->circ->due_date;
3808 return $date unless $setting and
3809 $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3811 my $xact = $self->editor->retrieve_money_billable_transaction([
3813 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3816 if ($xact->summary) {
3817 $date = $xact->summary->last_payment_ts ||
3818 $xact->summary->last_billing_ts ||
3819 $self->circ->due_date;
3826 sub checkin_handle_lost_or_longoverdue {
3827 my ($self, %args) = @_;
3829 my $circ = $self->circ;
3830 my $max_return = $args{max_return};
3831 my $circ_lib = $args{circ_lib};
3836 $self->get_circ_last_billing_activity(
3837 $circ_lib, $args{ous_use_last_activity});
3840 my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3841 $tm[5] -= 1 if $tm[5] > 0;
3842 my $due = timelocal(int($tm[1]), int($tm[2]),
3843 int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3846 OpenILS::Utils::DateTime->interval_to_seconds($max_return) + int($due);
3848 $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3849 "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3850 "DUE: $due LAST: $last_chance");
3852 $max_return = 0 if $today < $last_chance;
3858 $logger->info("circulator: check-in of lost/lo item exceeds max ".
3859 "return interval. skipping fine/fee voiding, etc.");
3861 } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3863 $logger->info("circulator: check-in of lost/lo item having a balance ".
3864 "of zero, skipping fine/fee voiding and reinstatement.");
3866 } else { # within max-return interval or no interval defined
3868 $logger->info("circulator: check-in of lost/lo item is within the ".
3869 "max return interval (or no interval is defined). Proceeding ".
3870 "with fine/fee voiding, etc.");
3872 $self->needs_lost_bill_handling(1);
3875 if ($circ_lib != $self->circ_lib) {
3876 # if the item is not home, check to see if we want to retain the
3877 # lost/longoverdue status at this point in the process
3879 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib,
3880 $args{ous_immediately_available}, $self->editor) || 0;
3882 if ($immediately_available) {
3883 # item status does not need to be retained, so give it a
3884 # reshelving status as if it were a normal checkin
3885 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3886 $self->copy->status($U->copy_status($next_status));
3889 $logger->info("circulator: leaving lost/longoverdue copy".
3890 " status in place on checkin");
3893 # lost/longoverdue item is home and processed, treat like a normal
3894 # checkin from this point on
3895 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3896 $self->copy->status($U->copy_status($next_status));
3902 sub checkin_handle_backdate {
3905 # ------------------------------------------------------------------
3906 # clean up the backdate for date comparison
3907 # XXX We are currently taking the due-time from the original due-date,
3908 # not the input. Do we need to do this? This certainly interferes with
3909 # backdating of hourly checkouts, but that is likely a very rare case.
3910 # ------------------------------------------------------------------
3911 my $bd = clean_ISO8601($self->backdate);
3912 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->circ->due_date));
3913 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3914 $new_date->set_hour($original_date->hour());
3915 $new_date->set_minute($original_date->minute());
3916 if ($new_date >= DateTime->now) {
3917 # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3918 # $self->backdate() autoload handler ignores undef values.
3919 # Clear the backdate manually.
3920 $logger->info("circulator: ignoring future backdate: $new_date");
3921 delete $self->{backdate};
3923 $self->backdate(clean_ISO8601($new_date->datetime()));
3930 sub check_checkin_copy_status {
3932 my $copy = $self->copy;
3934 my $status = $U->copy_status($copy->status)->id;
3937 if( $self->new_copy_alerts ||
3938 $status == OILS_COPY_STATUS_AVAILABLE ||
3939 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3940 $status == OILS_COPY_STATUS_IN_PROCESS ||
3941 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3942 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3943 $status == OILS_COPY_STATUS_CATALOGING ||
3944 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3945 $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
3946 $status == OILS_COPY_STATUS_RESHELVING );
3948 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3949 if( $status == OILS_COPY_STATUS_LOST );
3951 return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
3952 if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
3954 return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
3955 if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
3957 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3958 if( $status == OILS_COPY_STATUS_MISSING );
3960 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3965 # --------------------------------------------------------------------------
3966 # On checkin, we need to return as many relevant objects as we can
3967 # --------------------------------------------------------------------------
3968 sub checkin_flesh_events {
3971 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3972 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3973 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3976 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3979 if($self->hold and !$self->hold->cancel_time) {
3980 $hold = $self->hold;
3981 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3985 # update our copy of the circ object and
3986 # flesh the billing summary data
3988 $self->editor->retrieve_action_circulation([
3992 circ => ['billable_transaction'],
4001 # flesh some patron fields before returning
4003 $self->editor->retrieve_actor_user([
4008 au => ['card', 'billing_address', 'mailing_address']
4015 if ($self->latest_inventory) {
4016 # flesh some workstation fields before returning
4017 $self->latest_inventory->inventory_workstation(
4018 $self->editor->retrieve_actor_workstation([$self->latest_inventory->inventory_workstation])
4022 if($self->latest_inventory && !$self->latest_inventory->id) {
4023 my $alci = $self->editor->search_asset_latest_inventory(
4024 {copy => $self->latest_inventory->copy}
4027 $self->latest_inventory->id($alci->[0]->id);
4030 $self->copy->latest_inventory($self->latest_inventory);
4032 for my $evt (@{$self->events}) {
4035 $payload->{copy} = $U->unflesh_copy($self->copy);
4036 $payload->{volume} = $self->volume;
4037 $payload->{record} = $record,
4038 $payload->{circ} = $self->circ;
4039 $payload->{transit} = $self->transit;
4040 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
4041 $payload->{hold} = $hold;
4042 $payload->{patron} = $self->patron;
4043 $payload->{reservation} = $self->reservation
4044 unless (not $self->reservation or $self->reservation->cancel_time);
4045 $payload->{latest_inventory} = $self->latest_inventory;
4046 if ($self->do_inventory_update) { $payload->{do_inventory_update} = 1; }
4048 $evt->{payload} = $payload;
4053 my( $self, $msg ) = @_;
4054 my $bc = ($self->copy) ? $self->copy->barcode :
4057 my $usr = ($self->patron) ? $self->patron->id : "";
4058 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
4059 ", recipient=$usr, copy=$bc");
4066 $self->log_me("do_renew()");
4068 # Make sure there is an open circ to renew
4069 my $usrid = $self->patron->id if $self->patron;
4070 my $circ = $self->editor->search_action_circulation({
4071 target_copy => $self->copy->id,
4072 xact_finish => undef,
4073 checkin_time => undef,
4074 ($usrid ? (usr => $usrid) : ())
4077 return $self->bail_on_events($self->editor->event) unless $circ;
4079 # A user is not allowed to renew another user's items without permission
4080 unless( $circ->usr eq $self->editor->requestor->id ) {
4081 return $self->bail_on_events($self->editor->events)
4082 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
4085 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
4086 if $circ->renewal_remaining < 1;
4088 $self->push_events(OpenILS::Event->new('MAX_AUTO_RENEWALS_REACHED'))
4089 if $self->auto_renewal and $circ->auto_renewal_remaining < 1;
4090 # -----------------------------------------------------------------
4092 $self->parent_circ($circ->id);
4093 $self->renewal_remaining( $circ->renewal_remaining - 1 );
4094 $self->auto_renewal_remaining( $circ->auto_renewal_remaining - 1 ) if (defined($circ->auto_renewal_remaining));
4097 # Opac renewal - re-use circ library from original circ (unless told not to)
4098 if($self->opac_renewal or $self->auto_renewal) {
4099 unless(defined($opac_renewal_use_circ_lib)) {
4100 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
4101 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4102 $opac_renewal_use_circ_lib = 1;
4105 $opac_renewal_use_circ_lib = 0;
4108 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4111 # Desk renewal - re-use circ library from original circ (unless told not to)
4112 if($self->desk_renewal) {
4113 unless(defined($desk_renewal_use_circ_lib)) {
4114 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4115 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4116 $desk_renewal_use_circ_lib = 1;
4119 $desk_renewal_use_circ_lib = 0;
4122 $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
4125 # Run the fine generator against the old circ
4126 # XXX This seems unnecessary, given that handle_fines runs in do_checkin
4127 # a few lines down. Commenting out, for now.
4128 #$self->handle_fines;
4130 $self->run_renew_permit;
4133 $self->do_checkin();
4134 return if $self->bail_out;
4136 unless( $self->permit_override ) {
4138 return if $self->bail_out;
4139 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
4140 $self->remove_event('ITEM_NOT_CATALOGED');
4143 $self->override_events;
4144 return if $self->bail_out;
4147 $self->do_checkout();
4152 my( $self, $evt ) = @_;
4153 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4154 $logger->debug("circulator: removing event from list: $evt");
4155 my @events = @{$self->events};
4156 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
4161 my( $self, $evt ) = @_;
4162 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4163 return grep { $_->{textcode} eq $evt } @{$self->events};
4167 sub run_renew_permit {
4170 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
4171 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
4172 $self->editor, $self->copy, $self->editor->requestor, 1
4174 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
4177 my $results = $self->run_indb_circ_test;
4178 $self->push_events($self->matrix_test_result_events)
4179 unless $self->circ_test_success;
4183 # XXX: The primary mechanism for storing circ history is now handled
4184 # by tracking real circulation objects instead of bibs in a bucket.
4185 # However, this code is disabled by default and could be useful
4186 # some day, so may as well leave it for now.
4187 sub append_reading_list {
4191 $self->is_checkout and
4197 # verify history is globally enabled and uses the bucket mechanism
4198 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
4199 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
4201 return undef unless $htype and $htype eq 'bucket';
4203 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
4205 # verify the patron wants to retain the hisory
4206 my $setting = $e->search_actor_user_setting(
4207 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
4209 unless($setting and $setting->value) {
4214 my $bkt = $e->search_container_copy_bucket(
4215 {owner => $self->patron->id, btype => 'circ_history'})->[0];
4220 # find the next item position
4221 my $last_item = $e->search_container_copy_bucket_item(
4222 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
4223 $pos = $last_item->pos + 1 if $last_item;
4226 # create the history bucket if necessary
4227 $bkt = Fieldmapper::container::copy_bucket->new;
4228 $bkt->owner($self->patron->id);
4230 $bkt->btype('circ_history');
4232 $e->create_container_copy_bucket($bkt) or return $e->die_event;
4235 my $item = Fieldmapper::container::copy_bucket_item->new;
4237 $item->bucket($bkt->id);
4238 $item->target_copy($self->copy->id);
4241 $e->create_container_copy_bucket_item($item) or return $e->die_event;
4248 sub make_trigger_events {
4250 return unless $self->circ;
4251 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
4252 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
4253 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
4258 sub checkin_handle_lost_or_lo_now_found {
4259 my ($self, $bill_type, $is_longoverdue) = @_;
4261 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4263 $logger->debug("voiding $tag item billings");
4264 my $result = $CC->void_or_zero_bills_of_type($self->editor, $self->circ, $self->copy, $bill_type, "$tag ITEM RETURNED");
4265 $self->bail_on_events($self->editor->event) if ($result);
4268 sub checkin_handle_lost_or_lo_now_found_restore_od {
4270 my $circ_lib = shift;
4271 my $is_longoverdue = shift;
4272 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4274 # ------------------------------------------------------------------
4275 # restore those overdue charges voided when item was set to lost
4276 # ------------------------------------------------------------------
4278 my $ods = $self->editor->search_money_billing([
4280 xact => $self->circ->id,
4284 order_by => {mb => 'billing_ts desc'}
4288 $logger->debug("returning ".scalar(@$ods)." overdue charges pre-$tag");
4289 # Because actual users get up to all kinds of unexpectedness, we
4290 # only recreate up to $circ->max_fine in bills. I know you think
4291 # it wouldn't happen that bills could get created, voided, and
4292 # recreated more than once, but I guaran-damn-tee you that it will
4294 if ($ods && @$ods) {
4295 my $void_amount = 0;
4296 my $void_max = $self->circ->max_fine();
4297 # search for overdues voided the new way (aka "adjusted")
4298 my @billings = map {$_->id()} @$ods;
4299 my $voids = $self->editor->search_money_account_adjustment(
4301 billing => \@billings
4305 map {$void_amount += $_->amount()} @$voids;
4307 # if no adjustments found, assume they were voided the old way (aka "voided")
4308 for my $bill (@$ods) {
4309 if( $U->is_true($bill->voided) ) {
4310 $void_amount += $bill->amount();
4316 ($void_amount < $void_max ? $void_amount : $void_max),
4318 $ods->[0]->billing_type(),
4320 "System: $tag RETURNED - OVERDUES REINSTATED",
4321 $ods->[-1]->period_start(),
4322 $ods->[0]->period_end() # date this restoration the same as the last overdue (for possible subsequent fine generation)