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::Utils::CStoreEditor qw/:funcs/;
10 use OpenILS::Const qw/:const/;
11 use OpenILS::Application::AppUtils;
13 my $U = "OpenILS::Application::AppUtils";
17 my $opac_renewal_use_circ_lib;
18 my $desk_renewal_use_circ_lib;
20 sub determine_booking_status {
21 unless (defined $booking_status) {
22 my $ses = create OpenSRF::AppSession("router");
23 $booking_status = grep {$_ eq "open-ils.booking"} @{
24 $ses->request("opensrf.router.info.class.list")->gather(1)
27 $logger->info("booking status: " . ($booking_status ? "on" : "off"));
30 return $booking_status;
36 flesh_fields => {acp => ['call_number','parts','floating'], acn => ['record']}
39 # table of cases where suppressing a system-generated copy alerts
40 # should generate an override of an old-style event
41 my %COPY_ALERT_OVERRIDES = (
42 "CLAIMSRETURNED\tCHECKOUT" => ['CIRC_CLAIMS_RETURNED'],
43 "CLAIMSRETURNED\tCHECKIN" => ['CIRC_CLAIMS_RETURNED'],
44 "LOST\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
45 "LONGOVERDUE\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
46 "MISSING\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
47 "DAMAGED\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
48 "LOST_AND_PAID\tCHECKOUT" => ['COPY_NOT_AVAILABLE', 'OPEN_CIRCULATION_EXISTS']
53 __PACKAGE__->register_method(
54 method => "run_method",
55 api_name => "open-ils.circ.checkout.permit",
57 Determines if the given checkout can occur
58 @param authtoken The login session key
59 @param params A trailing hash of named params including
60 barcode : The copy barcode,
61 patron : The patron the checkout is occurring for,
62 renew : true or false - whether or not this is a renewal
63 @return The event that occurred during the permit check.
67 __PACKAGE__->register_method (
68 method => 'run_method',
69 api_name => 'open-ils.circ.checkout.permit.override',
70 signature => q/@see open-ils.circ.checkout.permit/,
74 __PACKAGE__->register_method(
75 method => "run_method",
76 api_name => "open-ils.circ.checkout",
79 @param authtoken The login session key
80 @param params A named hash of params including:
82 barcode If no copy is provided, the copy is retrieved via barcode
83 copyid If no copy or barcode is provide, the copy id will be use
84 patron The patron's id
85 noncat True if this is a circulation for a non-cataloted item
86 noncat_type The non-cataloged type id
87 noncat_circ_lib The location for the noncat circ.
88 precat The item has yet to be cataloged
89 dummy_title The temporary title of the pre-cataloded item
90 dummy_author The temporary authr of the pre-cataloded item
91 Default is the home org of the staff member
92 @return The SUCCESS event on success, any other event depending on the error
95 __PACKAGE__->register_method(
96 method => "run_method",
97 api_name => "open-ils.circ.checkin",
100 Generic super-method for handling all copies
101 @param authtoken The login session key
102 @param params Hash of named parameters including:
103 barcode - The copy barcode
104 force - If true, copies in bad statuses will be checked in and give good statuses
105 noop - don't capture holds or put items into transit
106 void_overdues - void all overdues for the circulation (aka amnesty)
111 __PACKAGE__->register_method(
112 method => "run_method",
113 api_name => "open-ils.circ.checkin.override",
114 signature => q/@see open-ils.circ.checkin/
117 __PACKAGE__->register_method(
118 method => "run_method",
119 api_name => "open-ils.circ.renew.override",
120 signature => q/@see open-ils.circ.renew/,
123 __PACKAGE__->register_method(
124 method => "run_method",
125 api_name => "open-ils.circ.renew",
126 notes => <<" NOTES");
127 PARAMS( authtoken, circ => circ_id );
128 open-ils.circ.renew(login_session, circ_object);
129 Renews the provided circulation. login_session is the requestor of the
130 renewal and if the logged in user is not the same as circ->usr, then
131 the logged in user must have RENEW_CIRC permissions.
134 __PACKAGE__->register_method(
135 method => "run_method",
136 api_name => "open-ils.circ.checkout.full"
138 __PACKAGE__->register_method(
139 method => "run_method",
140 api_name => "open-ils.circ.checkout.full.override"
142 __PACKAGE__->register_method(
143 method => "run_method",
144 api_name => "open-ils.circ.reservation.pickup"
146 __PACKAGE__->register_method(
147 method => "run_method",
148 api_name => "open-ils.circ.reservation.return"
150 __PACKAGE__->register_method(
151 method => "run_method",
152 api_name => "open-ils.circ.reservation.return.override"
154 __PACKAGE__->register_method(
155 method => "run_method",
156 api_name => "open-ils.circ.checkout.inspect",
157 desc => q/Returns the circ matrix test result and, on success, the rule set and matrix test object/
162 my( $self, $conn, $auth, $args ) = @_;
163 translate_legacy_args($args);
164 $args->{override_args} = { all => 1 } unless defined $args->{override_args};
165 $args->{new_copy_alerts} ||= $self->api_level > 1 ? 1 : 0;
166 my $api = $self->api_name;
169 OpenILS::Application::Circ::Circulator->new($auth, %$args);
171 return circ_events($circulator) if $circulator->bail_out;
173 # Before we run the requested method, let's make sure that the patron's
174 # threshold-based penalties are up-to-date, so that the method can take
175 # take them into consideration.
176 my $penalty_editor = new_editor(xact => 1, authtoken => $auth);
177 return $penalty_editor->event unless( $penalty_editor->checkauth );
178 OpenILS::Utils::Penalty->calculate_penalties($penalty_editor, $args->{patron_id}, $circulator->circ_lib);
179 $penalty_editor->commit;
181 $circulator->use_booking(determine_booking_status());
183 # --------------------------------------------------------------------------
184 # First, check for a booking transit, as the barcode may not be a copy
185 # barcode, but a resource barcode, and nothing else in here will work
186 # --------------------------------------------------------------------------
188 if ($circulator->use_booking && (my $bc = $circulator->copy_barcode) && $api !~ /checkout|inspect/) { # do we have a barcode?
189 my $resources = $circulator->editor->search_booking_resource( { barcode => $bc } ); # any resources by this barcode?
190 if (@$resources) { # yes!
192 my $res_id_list = [ map { $_->id } @$resources ];
193 my $transit = $circulator->editor->search_action_reservation_transit_copy(
195 { target_copy => $res_id_list, dest => $circulator->circ_lib, dest_recv_time => undef, cancel_time => undef },
196 { order_by => { artc => 'source_send_time' }, limit => 1 }
198 )->[0]; # Any transit for this barcode?
200 if ($transit) { # yes! unwrap it.
202 my $reservation = $circulator->editor->retrieve_booking_reservation( $transit->reservation );
203 my $res_type = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
205 my $success_event = new OpenILS::Event(
206 "SUCCESS", "payload" => {"reservation" => $reservation}
208 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
209 if (my $copy = $circulator->editor->search_asset_copy([
210 { barcode => $bc, deleted => 'f' }, $MK_ENV_FLESH
211 ])->[0]) { # got a copy
212 $copy->status( $transit->copy_status );
213 $copy->editor($circulator->editor->requestor->id);
214 $copy->edit_date('now');
215 $circulator->editor->update_asset_copy($copy);
216 $success_event->{"payload"}->{"record"} =
217 $U->record_to_mvr($copy->call_number->record);
218 $success_event->{"payload"}->{"volume"} = $copy->call_number;
219 $copy->call_number($copy->call_number->id);
220 $success_event->{"payload"}->{"copy"} = $copy;
224 $transit->dest_recv_time('now');
225 $circulator->editor->update_action_reservation_transit_copy( $transit );
227 $circulator->editor->commit;
228 # Formerly this branch just stopped here. Argh!
229 $conn->respond_complete($success_event);
235 if ($circulator->use_booking) {
236 $circulator->is_res_checkin($circulator->is_checkin(1))
237 if $api =~ /reservation.return/ or (
238 $api =~ /checkin/ and $circulator->seems_like_reservation()
241 $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
244 $circulator->is_renewal(1) if $api =~ /renew/;
245 $circulator->is_checkin(1) if $api =~ /checkin/;
246 $circulator->is_checkout(1) if $api =~ /checkout/;
247 $circulator->override(1) if $api =~ /override/o;
249 $circulator->mk_env();
250 $circulator->noop(1) if $circulator->claims_never_checked_out;
252 return circ_events($circulator) if $circulator->bail_out;
254 if( $api =~ /checkout\.permit/ ) {
255 $circulator->do_permit();
257 } elsif( $api =~ /checkout.full/ ) {
259 # requesting a precat checkout implies that any required
260 # overrides have been performed. Go ahead and re-override.
261 $circulator->skip_permit_key(1);
262 $circulator->override(1) if ( $circulator->request_precat && $circulator->editor->allowed('CREATE_PRECAT') );
263 $circulator->do_permit();
264 $circulator->is_checkout(1);
265 unless( $circulator->bail_out ) {
266 $circulator->events([]);
267 $circulator->do_checkout();
270 } elsif( $circulator->is_res_checkout ) {
271 $circulator->do_reservation_pickup();
273 } elsif( $api =~ /inspect/ ) {
274 my $data = $circulator->do_inspect();
275 $circulator->editor->rollback;
278 } elsif( $api =~ /checkout/ ) {
279 $circulator->do_checkout();
281 } elsif( $circulator->is_res_checkin ) {
282 $circulator->do_reservation_return();
283 $circulator->do_checkin() if ($circulator->copy());
284 } elsif( $api =~ /checkin/ ) {
285 $circulator->do_checkin();
287 } elsif( $api =~ /renew/ ) {
288 $circulator->do_renew($api);
291 if( $circulator->bail_out ) {
294 # make sure no success event accidentally slip in
296 [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
299 my @e = @{$circulator->events};
300 push( @ee, $_->{textcode} ) for @e;
301 $logger->info("circulator: bailing out with events: " . (join ", ", @ee));
303 $circulator->editor->rollback;
307 # checkin and reservation return can result in modifications to
308 # actor.usr.claims_never_checked_out_count without also modifying
309 # actor.last_xact_id. Perform a no-op update on the patron to
310 # force an update to last_xact_id.
311 if ($circulator->claims_never_checked_out && $circulator->patron) {
312 $circulator->editor->update_actor_user(
313 $circulator->editor->retrieve_actor_user($circulator->patron->id))
314 or return $circulator->editor->die_event;
317 $circulator->editor->commit;
320 $conn->respond_complete(circ_events($circulator));
322 return undef if $circulator->bail_out;
324 $circulator->do_hold_notify($circulator->notify_hold)
325 if $circulator->notify_hold;
326 $circulator->retarget_holds if $circulator->retarget;
327 $circulator->append_reading_list;
328 $circulator->make_trigger_events;
335 my @e = @{$circ->events};
336 # if we have multiple events, SUCCESS should not be one of them;
337 @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
338 return (@e == 1) ? $e[0] : \@e;
342 sub translate_legacy_args {
345 if( $$args{barcode} ) {
346 $$args{copy_barcode} = $$args{barcode};
347 delete $$args{barcode};
350 if( $$args{copyid} ) {
351 $$args{copy_id} = $$args{copyid};
352 delete $$args{copyid};
355 if( $$args{patronid} ) {
356 $$args{patron_id} = $$args{patronid};
357 delete $$args{patronid};
360 if( $$args{patron} and !ref($$args{patron}) ) {
361 $$args{patron_id} = $$args{patron};
362 delete $$args{patron};
366 if( $$args{noncat} ) {
367 $$args{is_noncat} = $$args{noncat};
368 delete $$args{noncat};
371 if( $$args{precat} ) {
372 $$args{is_precat} = $$args{request_precat} = $$args{precat};
373 delete $$args{precat};
379 # --------------------------------------------------------------------------
380 # This package actually manages all of the circulation logic
381 # --------------------------------------------------------------------------
382 package OpenILS::Application::Circ::Circulator;
383 use strict; use warnings;
384 use vars q/$AUTOLOAD/;
386 use OpenILS::Utils::Fieldmapper;
387 use OpenSRF::Utils::Cache;
388 use Digest::MD5 qw(md5_hex);
389 use DateTime::Format::ISO8601;
390 use OpenILS::Utils::PermitHold;
391 use OpenILS::Utils::DateTime qw/:datetime/;
392 use OpenSRF::Utils::SettingsClient;
393 use OpenILS::Application::Circ::Holds;
394 use OpenILS::Application::Circ::Transit;
395 use OpenSRF::Utils::Logger qw(:logger);
396 use OpenILS::Utils::CStoreEditor qw/:funcs/;
397 use OpenILS::Const qw/:const/;
398 use OpenILS::Utils::Penalty;
399 use OpenILS::Application::Circ::CircCommon;
402 my $CC = "OpenILS::Application::Circ::CircCommon";
403 my $holdcode = "OpenILS::Application::Circ::Holds";
404 my $transcode = "OpenILS::Application::Circ::Transit";
410 # --------------------------------------------------------------------------
411 # Add a pile of automagic getter/setter methods
412 # --------------------------------------------------------------------------
413 my @AUTOLOAD_FIELDS = qw/
425 overrides_per_copy_alerts
466 recurring_fines_level
471 auto_renewal_remaining
480 cancelled_hold_transit
488 circ_matrix_matchpoint
499 claims_never_checked_out
512 dont_change_lost_zero
514 needs_lost_bill_handling
520 my $type = ref($self) or die "$self is not an object";
522 my $name = $AUTOLOAD;
525 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
526 $logger->error("circulator: $type: invalid autoload field: $name");
527 die "$type: invalid autoload field: $name\n"
532 *{"${type}::${name}"} = sub {
535 $s->{$name} = $v if defined $v;
539 return $self->$name($data);
544 my( $class, $auth, %args ) = @_;
545 $class = ref($class) || $class;
546 my $self = bless( {}, $class );
549 $self->editor(new_editor(xact => 1, authtoken => $auth));
551 unless( $self->editor->checkauth ) {
552 $self->bail_on_events($self->editor->event);
556 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
558 $self->$_($args{$_}) for keys %args;
561 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
563 # if this is a renewal, default to desk_renewal
564 $self->desk_renewal(1) unless
565 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal
566 or $self->auto_renewal;
568 $self->capture('') unless $self->capture;
570 unless(%user_groups) {
571 my $gps = $self->editor->retrieve_all_permission_grp_tree;
572 %user_groups = map { $_->id => $_ } @$gps;
579 # --------------------------------------------------------------------------
580 # True if we should discontinue processing
581 # --------------------------------------------------------------------------
583 my( $self, $bool ) = @_;
584 if( defined $bool ) {
585 $logger->info("circulator: BAILING OUT") if $bool;
586 $self->{bail_out} = $bool;
588 return $self->{bail_out};
593 my( $self, @evts ) = @_;
596 $e->{payload} = $self->copy if
597 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
599 $logger->info("circulator: pushing event ".$e->{textcode});
600 push( @{$self->events}, $e ) unless
601 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
607 return '' if $self->skip_permit_key;
608 my $key = md5_hex( time() . rand() . "$$" );
609 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
610 return $self->permit_key($key);
613 sub check_permit_key {
615 return 1 if $self->skip_permit_key;
616 my $key = $self->permit_key;
617 return 0 unless $key;
618 my $k = "oils_permit_key_$key";
619 my $one = $self->cache_handle->get_cache($k);
620 $self->cache_handle->delete_cache($k);
621 return ($one) ? 1 : 0;
624 sub seems_like_reservation {
627 # Some words about the following method:
628 # 1) It requires the VIEW_USER permission, but that's not an
629 # issue, right, since all staff should have that?
630 # 2) It returns only one reservation at a time, even if an item can be
631 # and is currently overbooked. Hmmm....
632 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
633 my $result = $booking_ses->request(
634 "open-ils.booking.reservations.by_returnable_resource_barcode",
635 $self->editor->authtoken,
638 $booking_ses->disconnect;
640 return $self->bail_on_events($result) if defined $U->event_code($result);
643 $self->reservation(shift @$result);
651 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
652 sub save_trimmed_copy {
653 my ($self, $copy) = @_;
656 $self->volume($copy->call_number);
657 $self->title($self->volume->record);
658 $self->copy->call_number($self->volume->id);
659 $self->volume->record($self->title->id);
660 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
661 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
662 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
663 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
667 sub collect_user_copy_alerts {
669 my $e = $self->editor;
672 my $alerts = $e->search_asset_copy_alert([
673 {copy => $self->copy->id, ack_time => undef},
674 {flesh => 1, flesh_fields => { aca => [ qw/ alert_type / ] }}
676 if (ref $alerts eq "ARRAY") {
677 $logger->info("circulator: found " . scalar(@$alerts) . " alerts for copy " .
679 $self->user_copy_alerts($alerts);
684 sub filter_user_copy_alerts {
687 my $e = $self->editor;
689 if(my $alerts = $self->user_copy_alerts) {
691 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
692 my $suppressions = $e->search_actor_copy_alert_suppress(
693 {org => $suppress_orgs}
697 foreach my $a (@$alerts) {
698 # filter on event type
699 if (defined $a->alert_type) {
700 next if ($a->alert_type->event eq 'CHECKIN' && !$self->is_checkin && !$self->is_renewal);
701 next if ($a->alert_type->event eq 'CHECKOUT' && !$self->is_checkout && !$self->is_renewal);
702 next if (defined $a->alert_type->in_renew && $U->is_true($a->alert_type->in_renew) && !$self->is_renewal);
703 next if (defined $a->alert_type->in_renew && !$U->is_true($a->alert_type->in_renew) && $self->is_renewal);
706 # filter on suppression
707 next if (grep { $a->alert_type->id == $_->alert_type} @$suppressions);
709 # filter on "only at circ lib"
710 if (defined $a->alert_type->at_circ) {
711 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
712 $self->copy->circ_lib->id : $self->copy->circ_lib;
713 my $orgs = $U->get_org_descendants($copy_circ_lib);
715 if ($U->is_true($a->alert_type->invert_location)) {
716 next if (grep {$_ == $self->circ_lib} @$orgs);
718 next unless (grep {$_ == $self->circ_lib} @$orgs);
722 # filter on "only at owning lib"
723 if (defined $a->alert_type->at_owning) {
724 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
725 $self->volume->owning_lib->id : $self->volume->owning_lib;
726 my $orgs = $U->get_org_descendants($copy_owning_lib);
728 if ($U->is_true($a->alert_type->invert_location)) {
729 next if (grep {$_ == $self->circ_lib} @$orgs);
731 next unless (grep {$_ == $self->circ_lib} @$orgs);
735 $a->alert_type->next_status([$U->unique_unnested_numbers($a->alert_type->next_status)]);
737 push @final_alerts, $a;
740 $self->user_copy_alerts(\@final_alerts);
744 sub generate_system_copy_alerts {
746 return unless($self->copy);
748 # don't create system copy alerts if the copy
749 # is in a normal state; we're assuming that there's
750 # never a need to generate a popup for each and every
751 # checkin or checkout of normal items. If this assumption
752 # proves false, then we'll need to add a way to explicitly specify
753 # that a copy alert type should never generate a system copy alert
754 return if $self->copy_state eq 'NORMAL';
756 my $e = $self->editor;
758 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
759 my $suppressions = $e->search_actor_copy_alert_suppress(
760 {org => $suppress_orgs}
763 # events we care about ...
765 push(@$event, 'CHECKIN') if $self->is_checkin;
766 push(@$event, 'CHECKOUT') if $self->is_checkout;
767 return unless scalar(@$event);
769 my $alert_orgs = $U->get_org_ancestors($self->circ_lib);
770 my $alert_types = $e->search_config_copy_alert_type({
772 scope_org => $alert_orgs,
774 state => $self->copy_state,
775 '-or' => [ { in_renew => $self->is_renewal }, { in_renew => undef } ],
779 foreach my $a (@$alert_types) {
780 # filter on "only at circ lib"
781 if (defined $a->at_circ) {
782 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
783 $self->copy->circ_lib->id : $self->copy->circ_lib;
784 my $orgs = $U->get_org_descendants($copy_circ_lib);
786 if ($U->is_true($a->invert_location)) {
787 next if (grep {$_ == $self->circ_lib} @$orgs);
789 next unless (grep {$_ == $self->circ_lib} @$orgs);
793 # filter on "only at owning lib"
794 if (defined $a->at_owning) {
795 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
796 $self->volume->owning_lib->id : $self->volume->owning_lib;
797 my $orgs = $U->get_org_descendants($copy_owning_lib);
799 if ($U->is_true($a->invert_location)) {
800 next if (grep {$_ == $self->circ_lib} @$orgs);
802 next unless (grep {$_ == $self->circ_lib} @$orgs);
806 push @final_types, $a;
810 $logger->info("circulator: found " . scalar(@final_types) . " system alert types for copy" .
816 # keep track of conditions corresponding to suppressed
817 # system alerts, as these may be used to overridee
818 # certain old-style-events
819 my %auto_override_conditions = ();
820 foreach my $t (@final_types) {
821 if ($t->next_status) {
822 if (grep { $t->id == $_->alert_type } @$suppressions) {
825 $t->next_status([$U->unique_unnested_numbers($t->next_status)]);
829 my $alert = new Fieldmapper::asset::copy_alert ();
830 $alert->alert_type($t->id);
831 $alert->copy($self->copy->id);
833 $alert->create_staff($e->requestor->id);
834 $alert->create_time('now');
835 $alert->ack_staff($e->requestor->id);
836 $alert->ack_time('now');
838 $alert = $e->create_asset_copy_alert($alert);
842 $alert->alert_type($t->clone);
844 push(@{$self->next_copy_status}, @{$t->next_status}) if ($t->next_status);
845 if (grep {$_->alert_type == $t->id} @$suppressions) {
846 $auto_override_conditions{join("\t", $t->state, $t->event)} = 1;
848 push(@alerts, $alert) unless (grep {$_->alert_type == $t->id} @$suppressions);
851 $self->system_copy_alerts(\@alerts);
852 $self->overrides_per_copy_alerts(\%auto_override_conditions);
855 sub add_overrides_from_system_copy_alerts {
857 my $e = $self->editor;
859 foreach my $condition (keys %{$self->overrides_per_copy_alerts()}) {
860 if (exists $COPY_ALERT_OVERRIDES{$condition}) {
862 push @{$self->override_args->{events}}, @{ $COPY_ALERT_OVERRIDES{$condition} };
863 # special handling for long-overdue and lost checkouts
864 if (grep { $_ eq 'OPEN_CIRCULATION_EXISTS' } @{ $COPY_ALERT_OVERRIDES{$condition} }) {
865 my $state = (split /\t/, $condition, -1)[0];
867 if ($state eq 'LOST' or $state eq 'LOST_AND_PAID') {
868 $setting = 'circ.copy_alerts.forgive_fines_on_lost_checkin';
869 } elsif ($state eq 'LONGOVERDUE') {
870 $setting = 'circ.copy_alerts.forgive_fines_on_long_overdue_checkin';
874 my $forgive = $U->ou_ancestor_setting_value(
875 $self->circ_lib, $setting, $e
877 if ($U->is_true($forgive)) {
878 $self->void_overdues(1);
880 $self->noop(1); # do not attempt transits, just check it in
889 my $e = $self->editor;
891 $self->next_copy_status([]) unless (defined $self->next_copy_status);
892 $self->overrides_per_copy_alerts({}) unless (defined $self->overrides_per_copy_alerts);
894 # --------------------------------------------------------------------------
895 # Grab the fleshed copy
896 # --------------------------------------------------------------------------
897 unless($self->is_noncat) {
900 $copy = $e->retrieve_asset_copy(
901 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
903 } elsif( $self->copy_barcode ) {
905 $copy = $e->search_asset_copy(
906 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
907 } elsif( $self->reservation ) {
908 my $res = $e->json_query(
910 "select" => {"acp" => ["id"]},
915 "field" => "barcode",
919 "field" => "current_resource"
928 "id" => (ref $self->reservation) ?
929 $self->reservation->id : $self->reservation
934 if (ref $res eq "ARRAY" and scalar @$res) {
935 $logger->info("circulator: mapped reservation " .
936 $self->reservation . " to copy " . $res->[0]->{"id"});
937 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
942 $self->save_trimmed_copy($copy);
947 {from => ['asset.copy_state', $copy->id]}
948 )->[0]{'asset.copy_state'}
951 $self->generate_system_copy_alerts;
952 $self->add_overrides_from_system_copy_alerts;
953 $self->collect_user_copy_alerts;
954 $self->filter_user_copy_alerts;
957 # We can't renew if there is no copy
958 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
959 if $self->is_renewal;
964 # --------------------------------------------------------------------------
966 # --------------------------------------------------------------------------
970 flesh_fields => {au => [ qw/ card / ]}
973 if( $self->patron_id ) {
974 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
975 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
977 } elsif( $self->patron_barcode ) {
979 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
980 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
981 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
983 $patron = $e->retrieve_actor_user($card->usr)
984 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
986 # Use the card we looked up, not the patron's primary, for card active checks
987 $patron->card($card);
990 if( my $copy = $self->copy ) {
993 $flesh->{flesh_fields}->{circ} = ['usr'];
995 my $circ = $e->search_action_circulation([
996 {target_copy => $copy->id, checkin_time => undef}, $flesh
1000 $patron = $circ->usr;
1001 $circ->usr($patron->id); # de-flesh for consistency
1007 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
1008 unless $self->patron($patron) or $self->is_checkin;
1010 unless($self->is_checkin) {
1012 # Check for inactivity and patron reg. expiration
1014 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
1015 unless $U->is_true($patron->active);
1017 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
1018 unless $U->is_true($patron->card->active);
1020 # Expired patrons cannot check out. Renewals for expired
1021 # patrons depend on a setting and will be checked in the
1022 # do_renew subroutine.
1023 if ($self->is_checkout) {
1024 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1025 clean_ISO8601($patron->expire_date));
1027 if (CORE::time > $expire->epoch) {
1028 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1035 # --------------------------------------------------------------------------
1036 # Does the circ permit work
1037 # --------------------------------------------------------------------------
1041 $self->log_me("do_permit()");
1043 unless( $self->editor->requestor->id == $self->patron->id ) {
1044 return $self->bail_on_events($self->editor->event)
1045 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1048 $self->check_captured_holds();
1049 $self->do_copy_checks();
1050 return if $self->bail_out;
1051 $self->run_patron_permit_scripts();
1052 $self->run_copy_permit_scripts()
1053 unless $self->is_precat or $self->is_noncat;
1054 $self->check_item_deposit_events();
1055 $self->override_events();
1056 return if $self->bail_out;
1058 if($self->is_precat and not $self->request_precat) {
1060 OpenILS::Event->new(
1061 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1062 return $self->bail_out(1) unless $self->is_renewal;
1066 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1069 sub check_item_deposit_events {
1071 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
1072 if $self->is_deposit and not $self->is_deposit_exempt;
1073 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
1074 if $self->is_rental and not $self->is_rental_exempt;
1077 # returns true if the user is not required to pay deposits
1078 sub is_deposit_exempt {
1080 my $pid = (ref $self->patron->profile) ?
1081 $self->patron->profile->id : $self->patron->profile;
1082 my $groups = $U->ou_ancestor_setting_value(
1083 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1084 for my $grp (@$groups) {
1085 return 1 if $self->is_group_descendant($grp, $pid);
1090 # returns true if the user is not required to pay rental fees
1091 sub is_rental_exempt {
1093 my $pid = (ref $self->patron->profile) ?
1094 $self->patron->profile->id : $self->patron->profile;
1095 my $groups = $U->ou_ancestor_setting_value(
1096 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1097 for my $grp (@$groups) {
1098 return 1 if $self->is_group_descendant($grp, $pid);
1103 sub is_group_descendant {
1104 my($self, $p_id, $c_id) = @_;
1105 return 0 unless defined $p_id and defined $c_id;
1106 return 1 if $c_id == $p_id;
1107 while(my $grp = $user_groups{$c_id}) {
1108 $c_id = $grp->parent;
1109 return 0 unless defined $c_id;
1110 return 1 if $c_id == $p_id;
1115 sub check_captured_holds {
1117 my $copy = $self->copy;
1118 my $patron = $self->patron;
1120 return undef unless $copy;
1122 my $s = $U->copy_status($copy->status)->id;
1123 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1124 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1126 # Item is on the holds shelf, make sure it's going to the right person
1127 my $hold = $self->editor->search_action_hold_request(
1130 current_copy => $copy->id ,
1131 capture_time => { '!=' => undef },
1132 cancel_time => undef,
1133 fulfillment_time => undef
1137 flesh_fields => { ahr => ['usr'] }
1142 if ($hold and $hold->usr->id == $patron->id) {
1143 $self->checkout_is_for_hold(1);
1147 my $holdau = $hold->usr;
1150 $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1151 $payload->{patron_id} = $holdau->id;
1153 $payload->{patron_name} = "???";
1155 $payload->{hold_id} = $hold->id;
1156 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1157 payload => $payload));
1160 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1165 sub do_copy_checks {
1167 my $copy = $self->copy;
1168 return unless $copy;
1170 my $stat = $U->copy_status($copy->status)->id;
1172 # We cannot check out a copy if it is in-transit
1173 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1174 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1177 $self->handle_claims_returned();
1178 return if $self->bail_out;
1180 # no claims returned circ was found, check if there is any open circ
1181 unless( $self->is_renewal ) {
1183 my $circs = $self->editor->search_action_circulation(
1184 { target_copy => $copy->id, checkin_time => undef }
1187 if(my $old_circ = $circs->[0]) { # an open circ was found
1189 my $payload = {copy => $copy};
1191 if($old_circ->usr == $self->patron->id) {
1193 $payload->{old_circ} = $old_circ;
1195 # If there is an open circulation on the checkout item and an auto-renew
1196 # interval is defined, inform the caller that they should go
1197 # ahead and renew the item instead of warning about open circulations.
1199 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1201 'circ.checkout_auto_renew_age',
1205 if($auto_renew_intvl) {
1206 my $intvl_seconds = OpenILS::Utils::DateTime->interval_to_seconds($auto_renew_intvl);
1207 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( clean_ISO8601($old_circ->xact_start) );
1209 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1210 $payload->{auto_renew} = 1;
1215 return $self->bail_on_events(
1216 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1222 my $LEGACY_CIRC_EVENT_MAP = {
1223 'no_item' => 'ITEM_NOT_CATALOGED',
1224 'actor.usr.barred' => 'PATRON_BARRED',
1225 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1226 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1227 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1228 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1229 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1230 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1231 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1232 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1233 'config.circ_matrix_test.total_copy_hold_ratio' =>
1234 'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1235 'config.circ_matrix_test.available_copy_hold_ratio' =>
1236 'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1240 # ---------------------------------------------------------------------
1241 # This pushes any patron-related events into the list but does not
1242 # set bail_out for any events
1243 # ---------------------------------------------------------------------
1244 sub run_patron_permit_scripts {
1246 my $patronid = $self->patron->id;
1251 my $results = $self->run_indb_circ_test;
1252 unless($self->circ_test_success) {
1253 my @trimmed_results;
1255 if ($self->is_noncat) {
1256 # no_item result is OK during noncat checkout
1257 @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1261 if ($self->checkout_is_for_hold) {
1262 # if this checkout will fulfill a hold, ignore CIRC blocks
1263 # and rely instead on the (later-checked) FULFILL block
1265 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1266 my $fblock_pens = $self->editor->search_config_standing_penalty(
1267 {name => [@pen_names], block_list => {like => '%CIRC%'}});
1269 for my $res (@$results) {
1270 my $name = $res->{fail_part} || '';
1271 next if grep {$_->name eq $name} @$fblock_pens;
1272 push(@trimmed_results, $res);
1276 # not for hold or noncat
1277 @trimmed_results = @$results;
1281 # update the final set of test results
1282 $self->matrix_test_result(\@trimmed_results);
1284 push @allevents, $self->matrix_test_result_events;
1288 $_->{payload} = $self->copy if
1289 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1292 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1294 $self->push_events(@allevents);
1297 sub matrix_test_result_codes {
1299 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1302 sub matrix_test_result_events {
1305 my $event = new OpenILS::Event(
1306 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1308 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1310 } (@{$self->matrix_test_result});
1313 sub run_indb_circ_test {
1315 return $self->matrix_test_result if $self->matrix_test_result;
1317 my $dbfunc = ($self->is_renewal) ?
1318 'action.item_user_renew_test' : 'action.item_user_circ_test';
1320 if( $self->is_precat && $self->request_precat) {
1321 $self->make_precat_copy;
1322 return if $self->bail_out;
1325 my $results = $self->editor->json_query(
1329 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1335 $self->circ_test_success($U->is_true($results->[0]->{success}));
1337 if(my $mp = $results->[0]->{matchpoint}) {
1338 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1339 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1340 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1341 if(defined($results->[0]->{renewals})) {
1342 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1344 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1345 if(defined($results->[0]->{grace_period})) {
1346 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1348 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1349 if(defined($results->[0]->{hard_due_date})) {
1350 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1352 # Grab the *last* response for limit_groups, where it is more likely to be filled
1353 $self->limit_groups($results->[-1]->{limit_groups});
1356 return $self->matrix_test_result($results);
1359 # ---------------------------------------------------------------------
1360 # given a use and copy, this will calculate the circulation policy
1361 # parameters. Only works with in-db circ.
1362 # ---------------------------------------------------------------------
1366 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1368 $self->run_indb_circ_test;
1371 circ_test_success => $self->circ_test_success,
1372 failure_events => [],
1373 failure_codes => [],
1374 matchpoint => $self->circ_matrix_matchpoint
1377 unless($self->circ_test_success) {
1378 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1379 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1382 if($self->circ_matrix_matchpoint) {
1383 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1384 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1385 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1386 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1388 my $policy = $self->get_circ_policy(
1389 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1391 $$results{$_} = $$policy{$_} for keys %$policy;
1397 # ---------------------------------------------------------------------
1398 # Loads the circ policy info for duration, recurring fine, and max
1399 # fine based on the current copy
1400 # ---------------------------------------------------------------------
1401 sub get_circ_policy {
1402 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1405 duration_rule => $duration_rule->name,
1406 recurring_fine_rule => $recurring_fine_rule->name,
1407 max_fine_rule => $max_fine_rule->name,
1408 max_fine => $self->get_max_fine_amount($max_fine_rule),
1409 fine_interval => $recurring_fine_rule->recurrence_interval,
1410 renewal_remaining => $duration_rule->max_renewals,
1411 auto_renewal_remaining => $duration_rule->max_auto_renewals,
1412 grace_period => $recurring_fine_rule->grace_period
1415 if($hard_due_date) {
1416 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1417 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1420 $policy->{duration_date_ceiling} = undef;
1421 $policy->{duration_date_ceiling_force} = undef;
1424 $policy->{duration} = $duration_rule->shrt
1425 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1426 $policy->{duration} = $duration_rule->normal
1427 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1428 $policy->{duration} = $duration_rule->extended
1429 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1431 $policy->{recurring_fine} = $recurring_fine_rule->low
1432 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1433 $policy->{recurring_fine} = $recurring_fine_rule->normal
1434 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1435 $policy->{recurring_fine} = $recurring_fine_rule->high
1436 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1441 sub get_max_fine_amount {
1443 my $max_fine_rule = shift;
1444 my $max_amount = $max_fine_rule->amount;
1446 # if is_percent is true then the max->amount is
1447 # use as a percentage of the copy price
1448 if ($U->is_true($max_fine_rule->is_percent)) {
1449 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1450 $max_amount = $price * $max_fine_rule->amount / 100;
1452 $U->ou_ancestor_setting_value(
1454 'circ.max_fine.cap_at_price',
1458 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1459 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1467 sub run_copy_permit_scripts {
1469 my $copy = $self->copy || return;
1473 my $results = $self->run_indb_circ_test;
1474 push @allevents, $self->matrix_test_result_events
1475 unless $self->circ_test_success;
1477 # See if this copy has an alert message
1478 my $ae = $self->check_copy_alert();
1479 push( @allevents, $ae ) if $ae;
1481 # uniquify the events
1482 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1483 @allevents = values %hash;
1485 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1487 $self->push_events(@allevents);
1491 sub check_copy_alert {
1494 if ($self->new_copy_alerts) {
1496 push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts
1497 if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1499 push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts
1500 if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1503 $self->bail_out(1) if (!$self->override);
1504 return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1508 return undef if $self->is_renewal;
1509 return OpenILS::Event->new(
1510 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1511 if $self->copy and $self->copy->alert_message;
1517 # --------------------------------------------------------------------------
1518 # If the call is overriding and has permissions to override every collected
1519 # event, the are cleared. Any event that the caller does not have
1520 # permission to override, will be left in the event list and bail_out will
1522 # XXX We need code in here to cancel any holds/transits on copies
1523 # that are being force-checked out
1524 # --------------------------------------------------------------------------
1525 sub override_events {
1527 my @events = @{$self->events};
1528 return unless @events;
1529 my $oargs = $self->override_args;
1531 if(!$self->override) {
1532 return $self->bail_out(1)
1533 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1538 for my $e (@events) {
1539 my $tc = $e->{textcode};
1540 next if $tc eq 'SUCCESS';
1541 if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1542 my $ov = "$tc.override";
1543 $logger->info("circulator: attempting to override event: $ov");
1545 return $self->bail_on_events($self->editor->event)
1546 unless( $self->editor->allowed($ov) );
1548 return $self->bail_out(1);
1554 # --------------------------------------------------------------------------
1555 # If there is an open claimsreturn circ on the requested copy, close the
1556 # circ if overriding, otherwise bail out
1557 # --------------------------------------------------------------------------
1558 sub handle_claims_returned {
1560 my $copy = $self->copy;
1562 my $CR = $self->editor->search_action_circulation(
1564 target_copy => $copy->id,
1565 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1566 checkin_time => undef,
1570 return unless ($CR = $CR->[0]);
1574 # - If the caller has set the override flag, we will check the item in
1575 if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1577 $CR->checkin_time('now');
1578 $CR->checkin_scan_time('now');
1579 $CR->checkin_lib($self->circ_lib);
1580 $CR->checkin_workstation($self->editor->requestor->wsid);
1581 $CR->checkin_staff($self->editor->requestor->id);
1583 $evt = $self->editor->event
1584 unless $self->editor->update_action_circulation($CR);
1587 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1590 $self->bail_on_events($evt) if $evt;
1595 # --------------------------------------------------------------------------
1596 # This performs the checkout
1597 # --------------------------------------------------------------------------
1601 $self->log_me("do_checkout()");
1603 # make sure perms are good if this isn't a renewal
1604 unless( $self->is_renewal ) {
1605 return $self->bail_on_events($self->editor->event)
1606 unless( $self->editor->allowed('COPY_CHECKOUT') );
1609 # verify the permit key
1610 unless( $self->check_permit_key ) {
1611 if( $self->permit_override ) {
1612 return $self->bail_on_events($self->editor->event)
1613 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1615 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1619 # if this is a non-cataloged circ, build the circ and finish
1620 if( $self->is_noncat ) {
1621 $self->checkout_noncat;
1623 OpenILS::Event->new('SUCCESS',
1624 payload => { noncat_circ => $self->circ }));
1628 if( $self->is_precat ) {
1629 $self->make_precat_copy;
1630 return if $self->bail_out;
1632 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1633 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1636 $self->do_copy_checks;
1637 return if $self->bail_out;
1639 $self->run_checkout_scripts();
1640 return if $self->bail_out;
1642 $self->build_checkout_circ_object();
1643 return if $self->bail_out;
1645 my $modify_to_start = $self->booking_adjusted_due_date();
1646 return if $self->bail_out;
1648 $self->apply_modified_due_date($modify_to_start);
1649 return if $self->bail_out;
1651 return $self->bail_on_events($self->editor->event)
1652 unless $self->editor->create_action_circulation($self->circ);
1654 # refresh the circ to force local time zone for now
1655 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1657 if($self->limit_groups) {
1658 $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1661 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1663 return if $self->bail_out;
1665 $self->apply_deposit_fee();
1666 return if $self->bail_out;
1668 $self->handle_checkout_holds();
1669 return if $self->bail_out;
1671 # ------------------------------------------------------------------------------
1672 # Update the patron penalty info in the DB, now that the item is checked out and
1673 # may cause the patron to reach certain thresholds.
1674 # ------------------------------------------------------------------------------
1675 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1677 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1680 if($self->is_renewal) {
1681 # flesh the billing summary for the checked-in circ
1682 $pcirc = $self->editor->retrieve_action_circulation([
1684 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1689 OpenILS::Event->new('SUCCESS',
1691 copy => $U->unflesh_copy($self->copy),
1692 volume => $self->volume,
1693 circ => $self->circ,
1695 holds_fulfilled => $self->fulfilled_holds,
1696 deposit_billing => $self->deposit_billing,
1697 rental_billing => $self->rental_billing,
1698 parent_circ => $pcirc,
1699 patron => ($self->return_patron) ? $self->patron : undef,
1700 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1706 sub apply_deposit_fee {
1708 my $copy = $self->copy;
1710 ($self->is_deposit and not $self->is_deposit_exempt) or
1711 ($self->is_rental and not $self->is_rental_exempt);
1713 return if $self->is_deposit and $self->skip_deposit_fee;
1714 return if $self->is_rental and $self->skip_rental_fee;
1716 my $bill = Fieldmapper::money::billing->new;
1717 my $amount = $copy->deposit_amount;
1721 if($self->is_deposit) {
1722 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1724 $self->deposit_billing($bill);
1726 $billing_type = OILS_BILLING_TYPE_RENTAL;
1728 $self->rental_billing($bill);
1731 $bill->xact($self->circ->id);
1732 $bill->amount($amount);
1733 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1734 $bill->billing_type($billing_type);
1735 $bill->btype($btype);
1736 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1738 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1743 my $copy = $self->copy;
1745 my $stat = $copy->status if ref $copy->status;
1746 my $loc = $copy->location if ref $copy->location;
1747 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1749 $copy->status($stat->id) if $stat;
1750 $copy->location($loc->id) if $loc;
1751 $copy->circ_lib($circ_lib->id) if $circ_lib;
1752 $copy->editor($self->editor->requestor->id);
1753 $copy->edit_date('now');
1754 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1756 return $self->bail_on_events($self->editor->event)
1757 unless $self->editor->update_asset_copy($self->copy);
1759 $copy->status($U->copy_status($copy->status));
1760 $copy->location($loc) if $loc;
1761 $copy->circ_lib($circ_lib) if $circ_lib;
1764 sub update_reservation {
1766 my $reservation = $self->reservation;
1768 my $usr = $reservation->usr;
1769 my $target_rt = $reservation->target_resource_type;
1770 my $target_r = $reservation->target_resource;
1771 my $current_r = $reservation->current_resource;
1773 $reservation->usr($usr->id) if ref $usr;
1774 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1775 $reservation->target_resource($target_r->id) if ref $target_r;
1776 $reservation->current_resource($current_r->id) if ref $current_r;
1778 return $self->bail_on_events($self->editor->event)
1779 unless $self->editor->update_booking_reservation($self->reservation);
1782 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1783 $self->reservation($reservation);
1787 sub bail_on_events {
1788 my( $self, @evts ) = @_;
1789 $self->push_events(@evts);
1793 # ------------------------------------------------------------------------------
1794 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1795 # affects copies that will fulfill holds and CIRC affects all other copies.
1796 # If blocks exists, bail, push Events onto the event pile, and return true.
1797 # ------------------------------------------------------------------------------
1798 sub check_hold_fulfill_blocks {
1801 # With the addition of ignore_proximity in csp, we need to fetch
1802 # the proximity of both the circ_lib and the copy's circ_lib to
1803 # the patron's home_ou.
1804 my ($ou_prox, $copy_prox);
1805 my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1806 $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1807 $ou_prox = -1 unless (defined($ou_prox));
1808 my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1809 if ($copy_ou == $self->circ_lib) {
1810 # Save us the time of an extra query.
1811 $copy_prox = $ou_prox;
1813 $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1814 $copy_prox = -1 unless (defined($copy_prox));
1817 # See if the user has any penalties applied that prevent hold fulfillment
1818 my $pens = $self->editor->json_query({
1819 select => {csp => ['name', 'label']},
1820 from => {ausp => {csp => {}}},
1823 usr => $self->patron->id,
1824 org_unit => $U->get_org_full_path($self->circ_lib),
1826 {stop_date => undef},
1827 {stop_date => {'>' => 'now'}}
1831 block_list => {'like' => '%FULFILL%'},
1833 {ignore_proximity => undef},
1834 {ignore_proximity => {'<' => $ou_prox}},
1835 {ignore_proximity => {'<' => $copy_prox}}
1841 return 0 unless @$pens;
1843 for my $pen (@$pens) {
1844 $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1845 my $event = OpenILS::Event->new($pen->{name});
1846 $event->{desc} = $pen->{label};
1847 $self->push_events($event);
1850 $self->override_events;
1851 return $self->bail_out;
1855 # ------------------------------------------------------------------------------
1856 # When an item is checked out, see if we can fulfill a hold for this patron
1857 # ------------------------------------------------------------------------------
1858 sub handle_checkout_holds {
1860 my $copy = $self->copy;
1861 my $patron = $self->patron;
1863 my $e = $self->editor;
1864 $self->fulfilled_holds([]);
1866 # non-cats can't fulfill a hold
1867 return if $self->is_noncat;
1869 my $hold = $e->search_action_hold_request({
1870 current_copy => $copy->id ,
1871 cancel_time => undef,
1872 fulfillment_time => undef
1875 if($hold and $hold->usr != $patron->id) {
1876 # reset the hold since the copy is now checked out
1878 $logger->info("circulator: un-targeting hold ".$hold->id.
1879 " because copy ".$copy->id." is getting checked out");
1881 $hold->clear_prev_check_time;
1882 $hold->clear_current_copy;
1883 $hold->clear_capture_time;
1884 $hold->clear_shelf_time;
1885 $hold->clear_shelf_expire_time;
1886 $hold->clear_current_shelf_lib;
1888 return $self->bail_on_event($e->event)
1889 unless $e->update_action_hold_request($hold);
1895 $hold = $self->find_related_user_hold($copy, $patron) or return;
1896 $logger->info("circulator: found related hold to fulfill in checkout");
1899 return if $self->check_hold_fulfill_blocks;
1901 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1903 # if the hold was never officially captured, capture it.
1904 $hold->clear_hopeless_date;
1905 $hold->current_copy($copy->id);
1906 $hold->capture_time('now') unless $hold->capture_time;
1907 $hold->fulfillment_time('now');
1908 $hold->fulfillment_staff($e->requestor->id);
1909 $hold->fulfillment_lib($self->circ_lib);
1911 return $self->bail_on_events($e->event)
1912 unless $e->update_action_hold_request($hold);
1914 return $self->fulfilled_holds([$hold->id]);
1918 # ------------------------------------------------------------------------------
1919 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1920 # the patron directly targets the checked out item, see if there is another hold
1921 # for the patron that could be fulfilled by the checked out item. Fulfill the
1922 # oldest hold and only fulfill 1 of them.
1924 # For "another hold":
1926 # First, check for one that the copy matches via hold_copy_map, ensuring that
1927 # *any* hold type that this copy could fill may end up filled.
1929 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1930 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1931 # that are non-requestable to count as capturing those hold types.
1932 # ------------------------------------------------------------------------------
1933 sub find_related_user_hold {
1934 my($self, $copy, $patron) = @_;
1935 my $e = $self->editor;
1937 # holds on precat copies are always copy-level, so this call will
1938 # always return undef. Exit early.
1939 return undef if $self->is_precat;
1941 return undef unless $U->ou_ancestor_setting_value(
1942 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1944 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1946 select => {ahr => ['id']},
1955 fkey => 'current_copy',
1956 type => 'left' # there may be no current_copy
1963 fulfillment_time => undef,
1964 cancel_time => undef,
1966 {expire_time => undef},
1967 {expire_time => {'>' => 'now'}}
1971 target_copy => $self->copy->id
1975 {id => undef}, # left-join copy may be nonexistent
1976 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1980 order_by => {ahr => {request_time => {direction => 'asc'}}},
1984 my $hold_info = $e->json_query($args)->[0];
1985 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1986 return undef if $U->ou_ancestor_setting_value(
1987 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1989 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1991 select => {ahr => ['id']},
1996 fkey => 'current_copy',
1997 type => 'left' # there may be no current_copy
2004 fulfillment_time => undef,
2005 cancel_time => undef,
2007 {expire_time => undef},
2008 {expire_time => {'>' => 'now'}}
2015 target => $self->volume->id
2021 target => $self->title->id
2027 {id => undef}, # left-join copy may be nonexistent
2028 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2032 order_by => {ahr => {request_time => {direction => 'asc'}}},
2036 $hold_info = $e->json_query($args)->[0];
2037 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2042 sub run_checkout_scripts {
2055 my $hard_due_date_name;
2057 $self->run_indb_circ_test();
2058 $duration = $self->circ_matrix_matchpoint->duration_rule;
2059 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2060 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2061 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2063 $duration_name = $duration->name if $duration;
2064 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2067 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2068 return $self->bail_on_events($evt) if ($evt && !$nobail);
2070 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2071 return $self->bail_on_events($evt) if ($evt && !$nobail);
2073 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2074 return $self->bail_on_events($evt) if ($evt && !$nobail);
2076 if($hard_due_date_name) {
2077 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2078 return $self->bail_on_events($evt) if ($evt && !$nobail);
2084 # The item circulates with an unlimited duration
2088 $hard_due_date = undef;
2091 $self->duration_rule($duration);
2092 $self->recurring_fines_rule($recurring);
2093 $self->max_fine_rule($max_fine);
2094 $self->hard_due_date($hard_due_date);
2098 sub build_checkout_circ_object {
2101 my $circ = Fieldmapper::action::circulation->new;
2102 my $duration = $self->duration_rule;
2103 my $max = $self->max_fine_rule;
2104 my $recurring = $self->recurring_fines_rule;
2105 my $hard_due_date = $self->hard_due_date;
2106 my $copy = $self->copy;
2107 my $patron = $self->patron;
2108 my $duration_date_ceiling;
2109 my $duration_date_ceiling_force;
2113 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2114 $duration_date_ceiling = $policy->{duration_date_ceiling};
2115 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2117 my $dname = $duration->name;
2118 my $mname = $max->name;
2119 my $rname = $recurring->name;
2121 if($hard_due_date) {
2122 $hdname = $hard_due_date->name;
2125 $logger->debug("circulator: building circulation ".
2126 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2128 $circ->duration($policy->{duration});
2129 $circ->recurring_fine($policy->{recurring_fine});
2130 $circ->duration_rule($duration->name);
2131 $circ->recurring_fine_rule($recurring->name);
2132 $circ->max_fine_rule($max->name);
2133 $circ->max_fine($policy->{max_fine});
2134 $circ->fine_interval($recurring->recurrence_interval);
2135 $circ->renewal_remaining($duration->max_renewals);
2136 $circ->auto_renewal_remaining($duration->max_auto_renewals);
2137 $circ->grace_period($policy->{grace_period});
2141 $logger->info("circulator: copy found with an unlimited circ duration");
2142 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2143 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2144 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2145 $circ->renewal_remaining(0);
2146 $circ->grace_period(0);
2149 $circ->target_copy( $copy->id );
2150 $circ->usr( $patron->id );
2151 $circ->circ_lib( $self->circ_lib );
2152 $circ->workstation($self->editor->requestor->wsid)
2153 if defined $self->editor->requestor->wsid;
2155 # renewals maintain a link to the parent circulation
2156 $circ->parent_circ($self->parent_circ);
2158 if( $self->is_renewal ) {
2159 $circ->opac_renewal('t') if $self->opac_renewal;
2160 $circ->phone_renewal('t') if $self->phone_renewal;
2161 $circ->desk_renewal('t') if $self->desk_renewal;
2162 $circ->auto_renewal('t') if $self->auto_renewal;
2163 $circ->renewal_remaining($self->renewal_remaining);
2164 $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2165 $circ->circ_staff($self->editor->requestor->id);
2168 # if the user provided an overiding checkout time,
2169 # (e.g. the checkout really happened several hours ago), then
2170 # we apply that here. Does this need a perm??
2171 $circ->xact_start(clean_ISO8601($self->checkout_time))
2172 if $self->checkout_time;
2174 # if a patron is renewing, 'requestor' will be the patron
2175 $circ->circ_staff($self->editor->requestor->id);
2176 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2181 sub do_reservation_pickup {
2184 $self->log_me("do_reservation_pickup()");
2186 $self->reservation->pickup_time('now');
2189 $self->reservation->current_resource &&
2190 $U->is_true($self->reservation->target_resource_type->catalog_item)
2192 # We used to try to set $self->copy and $self->patron here,
2193 # but that should already be done.
2195 $self->run_checkout_scripts(1);
2197 my $duration = $self->duration_rule;
2198 my $max = $self->max_fine_rule;
2199 my $recurring = $self->recurring_fines_rule;
2201 if ($duration && $max && $recurring) {
2202 my $policy = $self->get_circ_policy($duration, $recurring, $max);
2204 my $dname = $duration->name;
2205 my $mname = $max->name;
2206 my $rname = $recurring->name;
2208 $logger->debug("circulator: updating reservation ".
2209 "with duration=$dname, maxfine=$mname, recurring=$rname");
2211 $self->reservation->fine_amount($policy->{recurring_fine});
2212 $self->reservation->max_fine($policy->{max_fine});
2213 $self->reservation->fine_interval($recurring->recurrence_interval);
2216 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2217 $self->update_copy();
2220 $self->reservation->fine_amount(
2221 $self->reservation->target_resource_type->fine_amount
2223 $self->reservation->max_fine(
2224 $self->reservation->target_resource_type->max_fine
2226 $self->reservation->fine_interval(
2227 $self->reservation->target_resource_type->fine_interval
2231 $self->update_reservation();
2234 sub do_reservation_return {
2236 my $request = shift;
2238 $self->log_me("do_reservation_return()");
2240 if (not ref $self->reservation) {
2241 my ($reservation, $evt) =
2242 $U->fetch_booking_reservation($self->reservation);
2243 return $self->bail_on_events($evt) if $evt;
2244 $self->reservation($reservation);
2247 $self->handle_fines(1);
2248 $self->reservation->return_time('now');
2249 $self->update_reservation();
2250 $self->reshelve_copy if $self->copy;
2252 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2253 $self->copy( $self->reservation->current_resource->catalog_item );
2257 sub booking_adjusted_due_date {
2259 my $circ = $self->circ;
2260 my $copy = $self->copy;
2262 return undef unless $self->use_booking;
2266 if( $self->due_date ) {
2268 return $self->bail_on_events($self->editor->event)
2269 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2271 $circ->due_date(clean_ISO8601($self->due_date));
2275 return unless $copy and $circ->due_date;
2278 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2279 if (@$booking_items) {
2280 my $booking_item = $booking_items->[0];
2281 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2283 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2284 my $shorten_circ_setting = $resource_type->elbow_room ||
2285 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2288 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2289 my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2290 resource => $booking_item->id
2291 , search_start => 'now'
2292 , search_end => $circ->due_date
2293 , fields => { cancel_time => undef, return_time => undef }
2295 $booking_ses->disconnect;
2297 throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2298 return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2300 my $dt_parser = DateTime::Format::ISO8601->new;
2301 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($circ->due_date) );
2303 for my $bid (@$bookings) {
2305 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2307 my $booking_start = $dt_parser->parse_datetime( clean_ISO8601($booking->start_time) );
2308 my $booking_end = $dt_parser->parse_datetime( clean_ISO8601($booking->end_time) );
2310 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2311 if ($booking_start < DateTime->now);
2314 if ($U->is_true($stop_circ_setting)) {
2315 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2317 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2318 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2321 # We set the circ duration here only to affect the logic that will
2322 # later (in a DB trigger) mangle the time part of the due date to
2323 # 11:59pm. Having any circ duration that is not a whole number of
2324 # days is enough to prevent the "correction."
2325 my $new_circ_duration = $due_date->epoch - time;
2326 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2327 $circ->duration("$new_circ_duration seconds");
2329 $circ->due_date(clean_ISO8601($due_date->strftime('%FT%T%z')));
2333 return $self->bail_on_events($self->editor->event)
2334 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2340 sub apply_modified_due_date {
2342 my $shift_earlier = shift;
2343 my $circ = $self->circ;
2344 my $copy = $self->copy;
2346 if( $self->due_date ) {
2348 return $self->bail_on_events($self->editor->event)
2349 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2351 $circ->due_date(clean_ISO8601($self->due_date));
2355 # if the due_date lands on a day when the location is closed
2356 return unless $copy and $circ->due_date;
2358 $self->extend_renewal_due_date if $self->is_renewal;
2360 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2362 # due-date overlap should be determined by the location the item
2363 # is checked out from, not the owning or circ lib of the item
2364 my $org = $self->circ_lib;
2366 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2367 " with an item due date of ".$circ->due_date );
2369 my $dateinfo = $U->storagereq(
2370 'open-ils.storage.actor.org_unit.closed_date.overlap',
2371 $org, $circ->due_date );
2374 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2375 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2377 # XXX make the behavior more dynamic
2378 # for now, we just push the due date to after the close date
2379 if ($shift_earlier) {
2380 $circ->due_date($dateinfo->{start});
2382 $circ->due_date($dateinfo->{end});
2388 sub extend_renewal_due_date {
2390 my $circ = $self->circ;
2391 my $matchpoint = $self->circ_matrix_matchpoint;
2393 return unless $U->is_true($matchpoint->renew_extends_due_date);
2395 my $prev_circ = $self->editor->retrieve_action_circulation($self->parent_circ);
2397 my $start_time = DateTime::Format::ISO8601->new
2398 ->parse_datetime(clean_ISO8601($prev_circ->xact_start))->epoch;
2400 my $prev_due_date = DateTime::Format::ISO8601->new
2401 ->parse_datetime(clean_ISO8601($prev_circ->due_date));
2403 my $due_date = DateTime::Format::ISO8601->new
2404 ->parse_datetime(clean_ISO8601($circ->due_date));
2406 my $prev_due_time = $prev_due_date->epoch;
2408 my $now_time = DateTime->now->epoch;
2410 return if $prev_due_time < $now_time; # Renewed circ was overdue.
2412 if (my $interval = $matchpoint->renew_extend_min_interval) {
2414 my $min_duration = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2415 my $checkout_duration = $now_time - $start_time;
2417 if ($checkout_duration < $min_duration) {
2418 # Renewal occurred too early in the cycle to result in an
2419 # extension of the due date on the renewal.
2421 # If the new due date falls before the due date of
2422 # the previous circulation, use the due date of the prev.
2423 # circ so the patron does not lose time.
2424 my $due = $due_date < $prev_due_date ? $prev_due_date : $due_date;
2425 $circ->due_date($due->strftime('%FT%T%z'));
2431 # Item was checked out long enough during the previous circulation
2432 # to consider extending the due date of the renewal to cover the gap.
2434 # Amount of the previous duration that was left unused.
2435 my $remaining_duration = $prev_due_time - $now_time;
2437 $due_date->add(seconds => $remaining_duration);
2439 # If the calculated due date falls before the due date of the previous
2440 # circulation, use the due date of the prev. circ so the patron does
2442 my $due = $due_date < $prev_due_date ? $prev_due_date : $due_date;
2444 $logger->info("circulator: renewal due date extension landed on due date: $due");
2446 $circ->due_date($due->strftime('%FT%T%z'));
2450 sub create_due_date {
2451 my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2453 # Look up circulating library's TZ, or else use client TZ, falling
2455 my $tz = $U->ou_ancestor_setting_value(
2461 my $due_date = $start_time ?
2462 DateTime::Format::ISO8601
2464 ->parse_datetime(clean_ISO8601($start_time))
2465 ->set_time_zone($tz) :
2466 DateTime->now(time_zone => $tz);
2468 # add the circ duration
2469 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($duration, $due_date));
2472 my $cdate = DateTime::Format::ISO8601
2474 ->parse_datetime(clean_ISO8601($date_ceiling))
2475 ->set_time_zone($tz);
2477 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2478 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2483 # return ISO8601 time with timezone
2484 return $due_date->strftime('%FT%T%z');
2489 sub make_precat_copy {
2491 my $copy = $self->copy;
2492 return $self->bail_on_events(OpenILS::Event->new('PERM_FAILURE'))
2493 unless $self->editor->allowed('CREATE_PRECAT') || $self->is_renewal;
2496 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2498 $copy->editor($self->editor->requestor->id);
2499 $copy->edit_date('now');
2500 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2501 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2502 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2503 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2504 $self->update_copy();
2508 $logger->info("circulator: Creating a new precataloged ".
2509 "copy in checkout with barcode " . $self->copy_barcode);
2511 $copy = Fieldmapper::asset::copy->new;
2512 $copy->circ_lib($self->circ_lib);
2513 $copy->creator($self->editor->requestor->id);
2514 $copy->editor($self->editor->requestor->id);
2515 $copy->barcode($self->copy_barcode);
2516 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2517 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2518 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2520 $copy->dummy_title($self->dummy_title || "");
2521 $copy->dummy_author($self->dummy_author || "");
2522 $copy->dummy_isbn($self->dummy_isbn || "");
2523 $copy->circ_modifier($self->circ_modifier);
2526 # See if we need to override the circ_lib for the copy with a configured circ_lib
2527 # Setting is shortname of the org unit
2528 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2529 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2531 if($precat_circ_lib) {
2532 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2535 $self->bail_on_events($self->editor->event);
2539 $copy->circ_lib($org->id);
2543 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2545 $self->push_events($self->editor->event);
2551 sub checkout_noncat {
2557 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2558 my $count = $self->noncat_count || 1;
2559 my $cotime = clean_ISO8601($self->checkout_time) || "";
2561 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2565 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2566 $self->editor->requestor->id,
2574 $self->push_events($evt);
2582 # if an item is in transit but the status doesn't agree, then we need to fix things.
2583 # The next two subs will hopefully do that
2584 sub fix_broken_transit_status {
2587 # Capture the transit so we don't have to fetch it again later during checkin
2588 # This used to live in sub check_transit_checkin_interval and later again in
2591 $self->editor->search_action_transit_copy(
2592 {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2596 if ($self->transit && $U->copy_status($self->copy->status)->id != OILS_COPY_STATUS_IN_TRANSIT) {
2597 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2598 " that is in-transit but without the In Transit status... fixing");
2599 $self->copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2600 # FIXME - do we want to make this permanent if the checkin bails?
2605 sub cancel_transit_if_circ_exists {
2607 if ($self->circ && $self->transit) {
2608 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2609 " that is in-transit AND circulating... aborting the transit");
2610 my $circ_ses = create OpenSRF::AppSession("open-ils.circ");
2611 my $result = $circ_ses->request(
2612 "open-ils.circ.transit.abort",
2613 $self->editor->authtoken,
2614 { 'transitid' => $self->transit->id }
2616 $logger->warn("circulator: transit abort result: ".$result);
2617 $circ_ses->disconnect;
2618 $self->transit(undef);
2622 # If a copy goes into transit and is then checked in before the transit checkin
2623 # interval has expired, push an event onto the overridable events list.
2624 sub check_transit_checkin_interval {
2627 # only concerned with in-transit items
2628 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2630 # no interval, no problem
2631 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2632 return unless $interval;
2634 # transit from X to X for whatever reason has no min interval
2635 return if $self->transit->source == $self->transit->dest;
2637 my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2638 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2639 my $horizon = $t_start->add(seconds => $seconds);
2641 # See if we are still within the transit checkin forbidden range
2642 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2643 if $horizon > DateTime->now;
2646 # Retarget local holds at checkin
2647 sub checkin_retarget {
2649 return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2650 return unless $self->is_checkin; # Renewals need not be checked
2651 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2652 return if $self->is_precat; # No holds for precats
2653 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2654 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2655 my $status = $U->copy_status($self->copy->status);
2656 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2657 # Specifically target items that are likely new (by status ID)
2658 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2659 my $location = $self->copy->location;
2660 if(!ref($location)) {
2661 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2662 $self->copy->location($location);
2664 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2666 # Fetch holds for the bib
2667 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2668 $self->editor->authtoken,
2671 capture_time => undef, # No touching captured holds
2672 frozen => 'f', # Don't bother with frozen holds
2673 pickup_lib => $self->circ_lib # Only holds actually here
2676 # Error? Skip the step.
2677 return if exists $result->{"ilsevent"};
2681 foreach my $holdlist (keys %{$result}) {
2682 push @$holds, @{$result->{$holdlist}};
2685 return if scalar(@$holds) == 0; # No holds, no retargeting
2687 # Check for parts on this copy
2688 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2689 my %parts_hash = ();
2690 %parts_hash = map {$_->part, 1} @$parts if @$parts;
2692 # Loop over holds in request-ish order
2693 # Stage 1: Get them into request-ish order
2694 # Also grab type and target for skipping low hanging ones
2695 $result = $self->editor->json_query({
2696 "select" => { "ahr" => ["id", "hold_type", "target"] },
2697 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2698 "where" => { "id" => $holds },
2700 { "class" => "pgt", "field" => "hold_priority"},
2701 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2702 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2703 { "class" => "ahr", "field" => "request_time"}
2708 if (ref $result eq "ARRAY" and scalar @$result) {
2709 foreach (@{$result}) {
2710 # Copy level, but not this copy?
2711 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2712 and $_->{target} != $self->copy->id);
2713 # Volume level, but not this volume?
2714 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2715 if(@$parts) { # We have parts?
2717 next if ($_->{hold_type} eq 'T');
2718 # Skip part holds for parts not on this copy
2719 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2721 # No parts, no part holds
2722 next if ($_->{hold_type} eq 'P');
2724 # So much for easy stuff, attempt a retarget!
2725 my $tresult = $U->simplereq(
2726 'open-ils.hold-targeter',
2727 'open-ils.hold-targeter.target',
2728 {hold => $_->{id}, find_copy => $self->copy->id}
2730 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2731 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2739 $self->log_me("do_checkin()");
2741 return $self->bail_on_events(
2742 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2745 # Never capture a deleted copy for a hold.
2746 $self->capture('nocapture') if $U->is_true($self->copy->deleted);
2748 $self->fix_broken_transit_status; # if applicable
2749 $self->check_transit_checkin_interval;
2750 $self->checkin_retarget;
2752 # the renew code and mk_env should have already found our circulation object
2753 unless( $self->circ ) {
2755 my $circs = $self->editor->search_action_circulation(
2756 { target_copy => $self->copy->id, checkin_time => undef });
2758 $self->circ($$circs[0]);
2760 # for now, just warn if there are multiple open circs on a copy
2761 $logger->warn("circulator: we have ".scalar(@$circs).
2762 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2764 $self->cancel_transit_if_circ_exists; # if applicable
2766 my $stat = $U->copy_status($self->copy->status)->id;
2768 # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2769 # differently if they are already paid for. We need to check for this
2770 # early since overdue generation is potentially affected.
2771 my $dont_change_lost_zero = 0;
2772 if ($stat == OILS_COPY_STATUS_LOST
2773 || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2774 || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2776 # LOST fine settings are controlled by the copy's circ lib, not the the
2778 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2779 $self->copy->circ_lib->id : $self->copy->circ_lib;
2780 $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2781 $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2782 $self->editor) || 0;
2784 # Don't assume there's always a circ based on copy status
2785 if ($dont_change_lost_zero && $self->circ) {
2786 my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2787 $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2790 $self->dont_change_lost_zero($dont_change_lost_zero);
2793 # Check if the copy can float to here. We need this for inventory
2794 # and to see if the copy needs to transit or stay here later.
2796 if ($self->copy->floating) {
2797 my $res = $self->editor->json_query(
2800 'evergreen.can_float',
2801 $self->copy->floating->id,
2802 $self->copy->circ_lib,
2807 $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res;
2810 # Do copy inventory if necessary.
2811 if ($self->do_inventory_update && ($self->circ_lib == $self->copy->circ_lib || $can_float)) {
2812 my $aci = Fieldmapper::asset::copy_inventory->new();
2813 $aci->inventory_date('now');
2814 $aci->inventory_workstation($self->editor->requestor->wsid);
2815 $aci->copy($self->copy->id());
2816 $self->editor->create_asset_copy_inventory($aci);
2817 $self->checkin_changed(1);
2820 if( $self->checkin_check_holds_shelf() ) {
2821 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2822 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2823 if($self->fake_hold_dest) {
2824 $self->hold->pickup_lib($self->circ_lib);
2826 $self->checkin_flesh_events;
2830 unless( $self->is_renewal ) {
2831 return $self->bail_on_events($self->editor->event)
2832 unless $self->editor->allowed('COPY_CHECKIN');
2835 $self->push_events($self->check_copy_alert());
2836 $self->push_events($self->check_checkin_copy_status());
2838 # if the circ is marked as 'claims returned', add the event to the list
2839 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2840 if ($self->circ and $self->circ->stop_fines
2841 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2843 $self->check_circ_deposit();
2845 # handle the overridable events
2846 $self->override_events unless $self->is_renewal;
2847 return if $self->bail_out;
2850 $self->checkin_handle_circ_start;
2851 return if $self->bail_out;
2853 if (!$dont_change_lost_zero) {
2854 # if this circ is LOST and we are configured to generate overdue
2855 # fines for lost items on checkin (to fill the gap between mark
2856 # lost time and when the fines would have naturally stopped), then
2857 # stop_fines is no longer valid and should be cleared.
2859 # stop_fines will be set again during the handle_fines() stage.
2860 # XXX should this setting come from the copy circ lib (like other
2861 # LOST settings), instead of the circulation circ lib?
2862 if ($stat == OILS_COPY_STATUS_LOST) {
2863 $self->circ->clear_stop_fines if
2864 $U->ou_ancestor_setting_value(
2866 OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2871 # Set stop_fines when claimed never checked out
2872 $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2874 # handle fines for this circ, including overdue gen if needed
2875 $self->handle_fines;
2878 # Void any item deposits if the library wants to
2879 $self->check_circ_deposit(1);
2881 $self->checkin_handle_circ_finish;
2882 return if $self->bail_out;
2883 $self->checkin_changed(1);
2885 } elsif( $self->transit ) {
2886 my $hold_transit = $self->process_received_transit;
2887 $self->checkin_changed(1);
2889 if( $self->bail_out ) {
2890 $self->checkin_flesh_events;
2894 if( my $e = $self->check_checkin_copy_status() ) {
2895 # If the original copy status is special, alert the caller
2896 my $ev = $self->events;
2897 $self->events([$e]);
2898 $self->override_events;
2899 return if $self->bail_out;
2903 if( $hold_transit or
2904 $U->copy_status($self->copy->status)->id
2905 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2908 if( $hold_transit ) {
2909 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2911 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2916 if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2918 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2919 $self->reshelve_copy(1);
2920 $self->cancelled_hold_transit(1);
2921 $self->notify_hold(0); # don't notify for cancelled holds
2922 $self->fake_hold_dest(0);
2923 return if $self->bail_out;
2925 } elsif ($hold and $hold->hold_type eq 'R') {
2927 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2928 $self->notify_hold(0); # No need to notify
2929 $self->fake_hold_dest(0);
2930 $self->noop(1); # Don't try and capture for other holds/transits now
2931 $self->update_copy();
2932 $hold->fulfillment_time('now');
2933 $self->bail_on_events($self->editor->event)
2934 unless $self->editor->update_action_hold_request($hold);
2938 # hold transited to correct location
2939 if($self->fake_hold_dest) {
2940 $hold->pickup_lib($self->circ_lib);
2942 $self->checkin_flesh_events;
2947 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2949 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2950 " that is in-transit, but there is no transit.. repairing");
2951 $self->reshelve_copy(1);
2952 return if $self->bail_out;
2955 if( $self->is_renewal ) {
2956 $self->finish_fines_and_voiding;
2957 return if $self->bail_out;
2958 $self->push_events(OpenILS::Event->new('SUCCESS'));
2962 # ------------------------------------------------------------------------------
2963 # Circulations and transits are now closed where necessary. Now go on to see if
2964 # this copy can fulfill a hold or needs to be routed to a different location
2965 # ------------------------------------------------------------------------------
2967 my $needed_for_something = 0; # formerly "needed_for_hold"
2969 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2971 if (!$self->remote_hold) {
2972 if ($self->use_booking) {
2973 my $potential_hold = $self->hold_capture_is_possible;
2974 my $potential_reservation = $self->reservation_capture_is_possible;
2976 if ($potential_hold and $potential_reservation) {
2977 $logger->info("circulator: item could fulfill either hold or reservation");
2978 $self->push_events(new OpenILS::Event(
2979 "HOLD_RESERVATION_CONFLICT",
2980 "hold" => $potential_hold,
2981 "reservation" => $potential_reservation
2983 return if $self->bail_out;
2984 } elsif ($potential_hold) {
2985 $needed_for_something =
2986 $self->attempt_checkin_hold_capture;
2987 } elsif ($potential_reservation) {
2988 $needed_for_something =
2989 $self->attempt_checkin_reservation_capture;
2992 $needed_for_something = $self->attempt_checkin_hold_capture;
2995 return if $self->bail_out;
2997 unless($needed_for_something) {
2998 my $circ_lib = (ref $self->copy->circ_lib) ?
2999 $self->copy->circ_lib->id : $self->copy->circ_lib;
3001 if( $self->remote_hold ) {
3002 $circ_lib = $self->remote_hold->pickup_lib;
3003 $logger->warn("circulator: Copy ".$self->copy->barcode.
3004 " is on a remote hold's shelf, sending to $circ_lib");
3007 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
3009 my $suppress_transit = 0;
3011 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
3012 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
3013 if($suppress_transit_source && $suppress_transit_source->{value}) {
3014 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
3015 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
3016 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
3017 $suppress_transit = 1;
3022 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
3023 # copy is where it needs to be, either for hold or reshelving
3025 $self->checkin_handle_precat();
3026 return if $self->bail_out;
3029 # copy needs to transit "home", or stick here if it's a floating copy
3030 if ($can_float && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # Yep, floating, stick here
3031 $self->checkin_changed(1);
3032 $self->copy->circ_lib( $self->circ_lib );
3035 my $bc = $self->copy->barcode;
3036 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
3037 $self->checkin_build_copy_transit($circ_lib);
3038 return if $self->bail_out;
3039 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
3043 } else { # no-op checkin
3044 # XXX floating items still stick where they are even with no-op checkin?
3045 if ($self->copy->floating && $can_float) {
3046 $self->checkin_changed(1);
3047 $self->copy->circ_lib( $self->circ_lib );
3052 if($self->claims_never_checked_out and
3053 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
3055 # the item was not supposed to be checked out to the user and should now be marked as missing
3056 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
3057 $self->copy->status($next_status);
3061 $self->reshelve_copy unless $needed_for_something;
3064 return if $self->bail_out;
3066 unless($self->checkin_changed) {
3068 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
3069 my $stat = $U->copy_status($self->copy->status)->id;
3071 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
3072 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
3073 $self->bail_out(1); # no need to commit anything
3077 $self->push_events(OpenILS::Event->new('SUCCESS'))
3078 unless @{$self->events};
3081 $self->finish_fines_and_voiding;
3083 OpenILS::Utils::Penalty->calculate_penalties(
3084 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3086 $self->checkin_flesh_events;
3090 sub finish_fines_and_voiding {
3092 return unless $self->circ;
3094 return unless $self->backdate or $self->void_overdues;
3096 # void overdues after fine generation to prevent concurrent DB access to overdue billings
3097 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3099 my $evt = $CC->void_or_zero_overdues(
3100 $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3102 return $self->bail_on_events($evt) if $evt;
3104 # Make sure the circ is open or closed as necessary.
3105 $evt = $U->check_open_xact($self->editor, $self->circ->id);
3106 return $self->bail_on_events($evt) if $evt;
3112 # if a deposit was paid for this item, push the event
3113 # if called with a truthy param perform the void, depending on settings
3114 sub check_circ_deposit {
3118 return unless $self->circ;
3120 my $deposit = $self->editor->search_money_billing(
3122 xact => $self->circ->id,
3124 }, {idlist => 1})->[0];
3126 return unless $deposit;
3129 my $void_on_checkin = $U->ou_ancestor_setting_value(
3130 $self->circ_lib,OILS_SETTING_VOID_ITEM_DEPOSIT_ON_CHECKIN,$self->editor);
3131 if ( $void_on_checkin ) {
3132 my $evt = $CC->void_bills($self->editor,[$deposit], "DEPOSIT ITEM RETURNED");
3133 return $evt if $evt;
3135 } else { # if void is unset this is just a check, notify that there was a deposit billing
3136 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_PAID', payload => $deposit));
3142 my $force = $self->force || shift;
3143 my $copy = $self->copy;
3145 my $stat = $U->copy_status($copy->status)->id;
3147 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3150 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3151 $stat != OILS_COPY_STATUS_CATALOGING and
3152 $stat != OILS_COPY_STATUS_IN_TRANSIT and
3153 $stat != $next_status )) {
3155 $copy->status( $next_status );
3157 $self->checkin_changed(1);
3162 # Returns true if the item is at the current location
3163 # because it was transited there for a hold and the
3164 # hold has not been fulfilled
3165 sub checkin_check_holds_shelf {
3167 return 0 unless $self->copy;
3170 $U->copy_status($self->copy->status)->id ==
3171 OILS_COPY_STATUS_ON_HOLDS_SHELF;
3173 # Attempt to clear shelf expired holds for this copy
3174 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3175 if($self->clear_expired);
3177 # find the hold that put us on the holds shelf
3178 my $holds = $self->editor->search_action_hold_request(
3180 current_copy => $self->copy->id,
3181 capture_time => { '!=' => undef },
3182 fulfillment_time => undef,
3183 cancel_time => undef,
3188 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3189 $self->reshelve_copy(1);
3193 my $hold = $$holds[0];
3195 $logger->info("circulator: we found a captured, un-fulfilled hold [".
3196 $hold->id. "] for copy ".$self->copy->barcode);
3198 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3199 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3200 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3201 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3202 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3203 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3204 $self->fake_hold_dest(1);
3210 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3211 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3215 $logger->info("circulator: hold is not for here..");
3216 $self->remote_hold($hold);
3221 sub checkin_handle_precat {
3223 my $copy = $self->copy;
3225 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3226 $copy->status(OILS_COPY_STATUS_CATALOGING);
3227 $self->update_copy();
3228 $self->checkin_changed(1);
3229 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3234 sub checkin_build_copy_transit {
3237 my $copy = $self->copy;
3238 my $transit = Fieldmapper::action::transit_copy->new;
3240 # if we are transiting an item to the shelf shelf, it's a hold transit
3241 if (my $hold = $self->remote_hold) {
3242 $transit = Fieldmapper::action::hold_transit_copy->new;
3243 $transit->hold($hold->id);
3245 # the item is going into transit, remove any shelf-iness
3246 if ($hold->current_shelf_lib or $hold->shelf_time) {
3247 $hold->clear_current_shelf_lib;
3248 $hold->clear_shelf_time;
3249 return $self->bail_on_events($self->editor->event)
3250 unless $self->editor->update_action_hold_request($hold);
3254 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3255 $logger->info("circulator: transiting copy to $dest");
3257 $transit->source($self->circ_lib);
3258 $transit->dest($dest);
3259 $transit->target_copy($copy->id);
3260 $transit->source_send_time('now');
3261 $transit->copy_status( $U->copy_status($copy->status)->id );
3263 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3265 if ($self->remote_hold) {
3266 return $self->bail_on_events($self->editor->event)
3267 unless $self->editor->create_action_hold_transit_copy($transit);
3269 return $self->bail_on_events($self->editor->event)
3270 unless $self->editor->create_action_transit_copy($transit);
3273 # ensure the transit is returned to the caller
3274 $self->transit($transit);
3276 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3278 $self->checkin_changed(1);
3282 sub hold_capture_is_possible {
3284 my $copy = $self->copy;
3286 # we've been explicitly told not to capture any holds
3287 return 0 if $self->capture eq 'nocapture';
3289 # See if this copy can fulfill any holds
3290 my $hold = $holdcode->find_nearest_permitted_hold(
3291 $self->editor, $copy, $self->editor->requestor, 1 # check_only
3293 return undef if ref $hold eq "HASH" and
3294 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3298 sub reservation_capture_is_possible {
3300 my $copy = $self->copy;
3302 # we've been explicitly told not to capture any holds
3303 return 0 if $self->capture eq 'nocapture';
3305 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3306 my $resv = $booking_ses->request(
3307 "open-ils.booking.reservations.could_capture",
3308 $self->editor->authtoken, $copy->barcode
3310 $booking_ses->disconnect;
3311 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3312 $self->push_events($resv);
3318 # returns true if the item was used (or may potentially be used
3319 # in subsequent calls) to capture a hold.
3320 sub attempt_checkin_hold_capture {
3322 my $copy = $self->copy;
3324 # we've been explicitly told not to capture any holds
3325 return 0 if $self->capture eq 'nocapture';
3327 # See if this copy can fulfill any holds
3328 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3329 $self->editor, $copy, $self->editor->requestor );
3332 $logger->debug("circulator: no potential permitted".
3333 "holds found for copy ".$copy->barcode);
3337 if($self->capture ne 'capture') {
3338 # see if this item is in a hold-capture-delay location
3339 my $location = $self->copy->location;
3340 if(!ref($location)) {
3341 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3342 $self->copy->location($location);
3344 if($U->is_true($location->hold_verify)) {
3345 $self->bail_on_events(
3346 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3351 $self->retarget($retarget);
3353 my $suppress_transit = 0;
3354 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3355 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3356 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3357 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3358 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3359 $suppress_transit = 1;
3360 $hold->pickup_lib($self->circ_lib);
3365 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3367 $hold->clear_hopeless_date;
3368 $hold->current_copy($copy->id);
3369 $hold->capture_time('now');
3370 $self->put_hold_on_shelf($hold)
3371 if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3373 # prevent DB errors caused by fetching
3374 # holds from storage, and updating through cstore
3375 $hold->clear_fulfillment_time;
3376 $hold->clear_fulfillment_staff;
3377 $hold->clear_fulfillment_lib;
3378 $hold->clear_expire_time;
3379 $hold->clear_cancel_time;
3380 $hold->clear_prev_check_time unless $hold->prev_check_time;
3382 $self->bail_on_events($self->editor->event)
3383 unless $self->editor->update_action_hold_request($hold);
3385 $self->checkin_changed(1);
3387 return 0 if $self->bail_out;
3389 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3391 if ($hold->hold_type eq 'R') {
3392 $copy->status(OILS_COPY_STATUS_CATALOGING);
3393 $hold->fulfillment_time('now');
3394 $self->noop(1); # Block other transit/hold checks
3395 $self->bail_on_events($self->editor->event)
3396 unless $self->editor->update_action_hold_request($hold);
3398 # This hold was captured in the correct location
3399 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3400 $self->push_events(OpenILS::Event->new('SUCCESS'));
3402 #$self->do_hold_notify($hold->id);
3403 $self->notify_hold($hold->id);
3408 # Hold needs to be picked up elsewhere. Build a hold
3409 # transit and route the item.
3410 $self->checkin_build_hold_transit();
3411 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3412 return 0 if $self->bail_out;
3413 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3416 # make sure we save the copy status
3418 return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3422 sub attempt_checkin_reservation_capture {
3424 my $copy = $self->copy;
3426 # we've been explicitly told not to capture any holds
3427 return 0 if $self->capture eq 'nocapture';
3429 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3430 my $evt = $booking_ses->request(
3431 "open-ils.booking.resources.capture_for_reservation",
3432 $self->editor->authtoken,
3434 1 # don't update copy - we probably have it locked
3436 $booking_ses->disconnect;
3438 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3440 "open-ils.booking.resources.capture_for_reservation " .
3441 "didn't return an event!"
3445 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3446 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3448 # not-transferable is an error event we'll pass on the user
3449 $logger->warn("reservation capture attempted against non-transferable item");
3450 $self->push_events($evt);
3452 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3453 # Re-retrieve copy as reservation capture may have changed
3454 # its status and whatnot.
3456 "circulator: booking capture win on copy " . $self->copy->id
3458 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3460 "circulator: changing copy " . $self->copy->id .
3461 "'s status from " . $self->copy->status . " to " .
3464 $self->copy->status($new_copy_status);
3467 $self->reservation($evt->{"payload"}->{"reservation"});
3469 if (exists $evt->{"payload"}->{"transit"}) {
3473 "org" => $evt->{"payload"}->{"transit"}->dest
3477 $self->checkin_changed(1);
3481 # other results are treated as "nothing to capture"
3485 sub do_hold_notify {
3486 my( $self, $holdid ) = @_;
3488 my $e = new_editor(xact => 1);
3489 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3491 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3492 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3494 $logger->info("circulator: running delayed hold notify process");
3496 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3497 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3499 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3500 hold_id => $holdid, requestor => $self->editor->requestor);
3502 $logger->debug("circulator: built hold notifier");
3504 if(!$notifier->event) {
3506 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3508 my $stat = $notifier->send_email_notify;
3509 if( $stat == '1' ) {
3510 $logger->info("circulator: hold notify succeeded for hold $holdid");
3514 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3517 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3521 sub retarget_holds {
3523 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3524 my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3525 $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3526 # no reason to wait for the return value
3530 sub checkin_build_hold_transit {
3533 my $copy = $self->copy;
3534 my $hold = $self->hold;
3535 my $trans = Fieldmapper::action::hold_transit_copy->new;
3537 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3539 $trans->hold($hold->id);
3540 $trans->source($self->circ_lib);
3541 $trans->dest($hold->pickup_lib);
3542 $trans->source_send_time("now");
3543 $trans->target_copy($copy->id);
3545 # when the copy gets to its destination, it will recover
3546 # this status - put it onto the holds shelf
3547 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3549 return $self->bail_on_events($self->editor->event)
3550 unless $self->editor->create_action_hold_transit_copy($trans);
3555 sub process_received_transit {
3557 my $copy = $self->copy;
3558 my $copyid = $self->copy->id;
3560 my $status_name = $U->copy_status($copy->status)->name;
3561 $logger->debug("circulator: attempting transit receive on ".
3562 "copy $copyid. Copy status is $status_name");
3564 my $transit = $self->transit;
3566 # Check if we are in a transit suppress range
3567 my $suppress_transit = 0;
3568 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3569 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3570 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3571 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3572 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3573 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3574 $suppress_transit = 1;
3575 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3579 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3580 # - this item is in-transit to a different location
3581 # - Or we are capturing holds as transits, so why create a new transit?
3583 my $tid = $transit->id;
3584 my $loc = $self->circ_lib;
3585 my $dest = $transit->dest;
3587 $logger->info("circulator: Fowarding transit on copy which is destined ".
3588 "for a different location. transit=$tid, copy=$copyid, current ".
3589 "location=$loc, destination location=$dest");
3591 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3593 # grab the associated hold object if available
3594 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3595 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3597 return $self->bail_on_events($evt);
3600 # The transit is received, set the receive time
3601 $transit->dest_recv_time('now');
3602 $self->bail_on_events($self->editor->event)
3603 unless $self->editor->update_action_transit_copy($transit);
3605 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3607 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3608 $copy->status( $transit->copy_status );
3609 $self->update_copy();
3610 return if $self->bail_out;
3614 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3617 # hold has arrived at destination, set shelf time
3618 $self->put_hold_on_shelf($hold);
3619 $self->bail_on_events($self->editor->event)
3620 unless $self->editor->update_action_hold_request($hold);
3621 return if $self->bail_out;
3623 $self->notify_hold($hold_transit->hold);
3626 $hold_transit = undef;
3627 $self->cancelled_hold_transit(1);
3628 $self->reshelve_copy(1);
3629 $self->fake_hold_dest(0);
3634 OpenILS::Event->new(
3637 payload => { transit => $transit, holdtransit => $hold_transit } ));
3639 return $hold_transit;
3643 # ------------------------------------------------------------------
3644 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3645 # ------------------------------------------------------------------
3646 sub put_hold_on_shelf {
3647 my($self, $hold) = @_;
3648 $hold->shelf_time('now');
3649 $hold->current_shelf_lib($self->circ_lib);
3650 $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3656 my $reservation = shift;
3657 my $dt_parser = DateTime::Format::ISO8601->new;
3659 my $obj = $reservation ? $self->reservation : $self->circ;
3661 my $lost_bill_opts = $self->lost_bill_options;
3662 my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3663 # first, restore any voided overdues for lost, if needed
3664 if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3665 my $restore_od = $U->ou_ancestor_setting_value(
3666 $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3667 $self->editor) || 0;
3668 $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3672 # next, handle normal overdue generation and apply stop_fines
3673 # XXX reservations don't have stop_fines
3674 # TODO revisit booking_reservation re: stop_fines support
3675 if ($reservation or !$obj->stop_fines) {
3678 # This is a crude check for whether we are in a grace period. The code
3679 # in generate_fines() does a more thorough job, so this exists solely
3680 # as a small optimization, and might be better off removed.
3682 # If we have a grace period
3683 if($obj->can('grace_period')) {
3684 # Parse out the due date
3685 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($obj->due_date) );
3686 # Add the grace period to the due date
3687 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($obj->grace_period));
3688 # Don't generate fines on circs still in grace period
3689 $skip_for_grace = $due_date > DateTime->now;
3691 $CC->generate_fines({circs => [$obj], editor => $self->editor})
3692 unless $skip_for_grace;
3694 if (!$reservation and !$obj->stop_fines) {
3695 $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3696 $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3697 $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3698 $obj->stop_fines_time('now');
3699 $obj->stop_fines_time($self->backdate) if $self->backdate;
3700 $self->editor->update_action_circulation($obj);
3704 # finally, handle voiding of lost item and processing fees
3705 if ($self->needs_lost_bill_handling) {
3706 my $void_cost = $U->ou_ancestor_setting_value(
3707 $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3708 $self->editor) || 0;
3709 my $void_proc_fee = $U->ou_ancestor_setting_value(
3710 $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3711 $self->editor) || 0;
3712 $self->checkin_handle_lost_or_lo_now_found(
3713 $lost_bill_opts->{void_cost_btype},
3714 $lost_bill_opts->{is_longoverdue}) if $void_cost;
3715 $self->checkin_handle_lost_or_lo_now_found(
3716 $lost_bill_opts->{void_fee_btype},
3717 $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3723 sub checkin_handle_circ_start {
3725 my $circ = $self->circ;
3726 my $copy = $self->copy;
3730 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3732 # backdate the circ if necessary
3733 if($self->backdate) {
3734 my $evt = $self->checkin_handle_backdate;
3735 return $self->bail_on_events($evt) if $evt;
3738 # Set the checkin vars since we have the item
3739 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3741 # capture the true scan time for back-dated checkins
3742 $circ->checkin_scan_time('now');
3744 $circ->checkin_staff($self->editor->requestor->id);
3745 $circ->checkin_lib($self->circ_lib);
3746 $circ->checkin_workstation($self->editor->requestor->wsid);
3748 my $circ_lib = (ref $self->copy->circ_lib) ?
3749 $self->copy->circ_lib->id : $self->copy->circ_lib;
3750 my $stat = $U->copy_status($self->copy->status)->id;
3752 if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3753 # we will now handle lost fines, but the copy will retain its 'lost'
3754 # status if it needs to transit home unless lost_immediately_available
3757 # if we decide to also delay fine handling until the item arrives home,
3758 # we will need to call lost fine handling code both when checking items
3759 # in and also when receiving transits
3760 $self->checkin_handle_lost($circ_lib);
3761 } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3762 # same process as above.
3763 $self->checkin_handle_long_overdue($circ_lib);
3764 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3765 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3767 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3768 $self->copy->status($U->copy_status($next_status));
3775 sub checkin_handle_circ_finish {
3777 my $e = $self->editor;
3778 my $circ = $self->circ;
3780 # Do one last check before the final circulation update to see
3781 # if the xact_finish value should be set or not.
3783 # The underlying money.billable_xact may have been updated to
3784 # reflect a change in xact_finish during checkin bills handling,
3785 # however we can't simply refresh the circulation from the DB,
3786 # because other changes may be pending. Instead, reproduce the
3787 # xact_finish check here. It won't hurt to do it again.
3789 my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3790 if ($sum) { # is this test still needed?
3792 my $balance = $sum->balance_owed;
3794 if ($balance == 0) {
3795 $circ->xact_finish('now');
3797 $circ->clear_xact_finish;
3800 $logger->info("circulator: $balance is owed on this circulation");
3803 return $self->bail_on_events($e->event)
3804 unless $e->update_action_circulation($circ);
3809 # ------------------------------------------------------------------
3810 # See if we need to void billings, etc. for lost checkin
3811 # ------------------------------------------------------------------
3812 sub checkin_handle_lost {
3814 my $circ_lib = shift;
3816 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3817 OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3819 $self->lost_bill_options({
3820 circ_lib => $circ_lib,
3821 ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3822 ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3823 ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3824 void_cost_btype => 3,
3828 return $self->checkin_handle_lost_or_longoverdue(
3829 circ_lib => $circ_lib,
3830 max_return => $max_return,
3831 ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3832 ous_use_last_activity => undef # not supported for LOST checkin
3836 # ------------------------------------------------------------------
3837 # See if we need to void billings, etc. for long-overdue checkin
3838 # note: not using constants below since they serve little purpose
3839 # for single-use strings that are descriptive in their own right
3840 # and mostly just complicate debugging.
3841 # ------------------------------------------------------------------
3842 sub checkin_handle_long_overdue {
3844 my $circ_lib = shift;
3846 $logger->info("circulator: processing long-overdue checkin...");
3848 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3849 'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3851 $self->lost_bill_options({
3852 circ_lib => $circ_lib,
3853 ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3854 ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3855 is_longoverdue => 1,
3856 ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3857 void_cost_btype => 10,
3858 void_fee_btype => 11
3861 return $self->checkin_handle_lost_or_longoverdue(
3862 circ_lib => $circ_lib,
3863 max_return => $max_return,
3864 ous_immediately_available => 'circ.longoverdue_immediately_available',
3865 ous_use_last_activity =>
3866 'circ.longoverdue.use_last_activity_date_on_return'
3870 # last billing activity is last payment time, last billing time, or the
3871 # circ due date. If the relevant "use last activity" org unit setting is
3872 # false/unset, then last billing activity is always the due date.
3873 sub get_circ_last_billing_activity {
3875 my $circ_lib = shift;
3876 my $setting = shift;
3877 my $date = $self->circ->due_date;
3879 return $date unless $setting and
3880 $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3882 my $xact = $self->editor->retrieve_money_billable_transaction([
3884 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3887 if ($xact->summary) {
3888 $date = $xact->summary->last_payment_ts ||
3889 $xact->summary->last_billing_ts ||
3890 $self->circ->due_date;
3897 sub checkin_handle_lost_or_longoverdue {
3898 my ($self, %args) = @_;
3900 my $circ = $self->circ;
3901 my $max_return = $args{max_return};
3902 my $circ_lib = $args{circ_lib};
3907 $self->get_circ_last_billing_activity(
3908 $circ_lib, $args{ous_use_last_activity});
3911 my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3912 $tm[5] -= 1 if $tm[5] > 0;
3913 my $due = timelocal(int($tm[1]), int($tm[2]),
3914 int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3917 OpenILS::Utils::DateTime->interval_to_seconds($max_return) + int($due);
3919 $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3920 "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3921 "DUE: $due LAST: $last_chance");
3923 $max_return = 0 if $today < $last_chance;
3929 $logger->info("circulator: check-in of lost/lo item exceeds max ".
3930 "return interval. skipping fine/fee voiding, etc.");
3932 } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3934 $logger->info("circulator: check-in of lost/lo item having a balance ".
3935 "of zero, skipping fine/fee voiding and reinstatement.");
3937 } else { # within max-return interval or no interval defined
3939 $logger->info("circulator: check-in of lost/lo item is within the ".
3940 "max return interval (or no interval is defined). Proceeding ".
3941 "with fine/fee voiding, etc.");
3943 $self->needs_lost_bill_handling(1);
3946 if ($circ_lib != $self->circ_lib) {
3947 # if the item is not home, check to see if we want to retain the
3948 # lost/longoverdue status at this point in the process
3950 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib,
3951 $args{ous_immediately_available}, $self->editor) || 0;
3953 if ($immediately_available) {
3954 # item status does not need to be retained, so give it a
3955 # reshelving status as if it were a normal checkin
3956 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3957 $self->copy->status($U->copy_status($next_status));
3960 $logger->info("circulator: leaving lost/longoverdue copy".
3961 " status in place on checkin");
3964 # lost/longoverdue item is home and processed, treat like a normal
3965 # checkin from this point on
3966 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3967 $self->copy->status($U->copy_status($next_status));
3973 sub checkin_handle_backdate {
3976 # ------------------------------------------------------------------
3977 # clean up the backdate for date comparison
3978 # XXX We are currently taking the due-time from the original due-date,
3979 # not the input. Do we need to do this? This certainly interferes with
3980 # backdating of hourly checkouts, but that is likely a very rare case.
3981 # ------------------------------------------------------------------
3982 my $bd = clean_ISO8601($self->backdate);
3983 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->circ->due_date));
3984 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3985 $new_date->set_hour($original_date->hour());
3986 $new_date->set_minute($original_date->minute());
3987 if ($new_date >= DateTime->now) {
3988 # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3989 # $self->backdate() autoload handler ignores undef values.
3990 # Clear the backdate manually.
3991 $logger->info("circulator: ignoring future backdate: $new_date");
3992 delete $self->{backdate};
3994 $self->backdate(clean_ISO8601($new_date->datetime()));
4001 sub check_checkin_copy_status {
4003 my $copy = $self->copy;
4005 my $status = $U->copy_status($copy->status)->id;
4008 if( $self->new_copy_alerts ||
4009 $status == OILS_COPY_STATUS_AVAILABLE ||
4010 $status == OILS_COPY_STATUS_CHECKED_OUT ||
4011 $status == OILS_COPY_STATUS_IN_PROCESS ||
4012 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
4013 $status == OILS_COPY_STATUS_IN_TRANSIT ||
4014 $status == OILS_COPY_STATUS_CATALOGING ||
4015 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
4016 $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
4017 $status == OILS_COPY_STATUS_RESHELVING );
4019 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
4020 if( $status == OILS_COPY_STATUS_LOST );
4022 return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
4023 if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
4025 return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
4026 if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
4028 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
4029 if( $status == OILS_COPY_STATUS_MISSING );
4031 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
4036 # --------------------------------------------------------------------------
4037 # On checkin, we need to return as many relevant objects as we can
4038 # --------------------------------------------------------------------------
4039 sub checkin_flesh_events {
4042 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
4043 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
4044 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
4047 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
4050 if($self->hold and !$self->hold->cancel_time) {
4051 $hold = $self->hold;
4052 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
4056 # update our copy of the circ object and
4057 # flesh the billing summary data
4059 $self->editor->retrieve_action_circulation([
4063 circ => ['billable_transaction'],
4072 # flesh some patron fields before returning
4074 $self->editor->retrieve_actor_user([
4079 au => ['card', 'billing_address', 'mailing_address']
4086 # Flesh the latest inventory.
4087 # NB: This survives the unflesh_copy below. Let's keep it that way.
4088 my $alci = $self->editor->search_asset_latest_inventory([
4089 {copy=>$self->copy->id},
4092 alci => ['inventory_workstation']
4094 if ($alci && $alci->[0]) {
4095 $self->copy->latest_inventory($alci->[0]);
4098 for my $evt (@{$self->events}) {
4101 $payload->{copy} = $U->unflesh_copy($self->copy);
4102 $payload->{volume} = $self->volume;
4103 $payload->{record} = $record,
4104 $payload->{circ} = $self->circ;
4105 $payload->{transit} = $self->transit;
4106 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
4107 $payload->{hold} = $hold;
4108 $payload->{patron} = $self->patron;
4109 $payload->{reservation} = $self->reservation
4110 unless (not $self->reservation or $self->reservation->cancel_time);
4112 $evt->{payload} = $payload;
4117 my( $self, $msg ) = @_;
4118 my $bc = ($self->copy) ? $self->copy->barcode :
4119 $self->copy_barcode;
4121 my $usr = ($self->patron) ? $self->patron->id : "";
4122 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
4123 ", recipient=$usr, copy=$bc");
4130 $self->log_me("do_renew()");
4132 # Make sure there is an open circ to renew
4133 my $usrid = $self->patron->id if $self->patron;
4134 my $circ = $self->editor->search_action_circulation({
4135 target_copy => $self->copy->id,
4136 xact_finish => undef,
4137 checkin_time => undef,
4138 ($usrid ? (usr => $usrid) : ())
4141 return $self->bail_on_events($self->editor->event) unless $circ;
4143 # A user is not allowed to renew another user's items without permission
4144 unless( $circ->usr eq $self->editor->requestor->id ) {
4145 return $self->bail_on_events($self->editor->events)
4146 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
4149 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
4150 if $circ->renewal_remaining < 1;
4152 $self->push_events(OpenILS::Event->new('MAX_AUTO_RENEWALS_REACHED'))
4153 if $self->auto_renewal and $circ->auto_renewal_remaining < 1;
4154 # -----------------------------------------------------------------
4156 $self->parent_circ($circ->id);
4157 $self->renewal_remaining( $circ->renewal_remaining - 1 );
4158 $self->auto_renewal_remaining( $circ->auto_renewal_remaining - 1 ) if (defined($circ->auto_renewal_remaining));
4161 # Opac renewal - re-use circ library from original circ (unless told not to)
4162 if($self->opac_renewal or $self->auto_renewal) {
4163 unless(defined($opac_renewal_use_circ_lib)) {
4164 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
4165 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4166 $opac_renewal_use_circ_lib = 1;
4169 $opac_renewal_use_circ_lib = 0;
4172 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4175 # Desk renewal - re-use circ library from original circ (unless told not to)
4176 if($self->desk_renewal) {
4177 unless(defined($desk_renewal_use_circ_lib)) {
4178 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4179 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4180 $desk_renewal_use_circ_lib = 1;
4183 $desk_renewal_use_circ_lib = 0;
4186 $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
4189 # Check if expired patron is allowed to renew, and bail if not.
4190 my $expire = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->patron->expire_date));
4191 if (CORE::time > $expire->epoch) {
4192 my $allow_renewal = $U->ou_ancestor_setting_value($self->circ_lib, OILS_SETTING_ALLOW_RENEW_FOR_EXPIRED_PATRON);
4193 unless ($U->is_true($allow_renewal)) {
4194 return $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'));
4198 # Run the fine generator against the old circ
4199 # XXX This seems unnecessary, given that handle_fines runs in do_checkin
4200 # a few lines down. Commenting out, for now.
4201 #$self->handle_fines;
4203 $self->run_renew_permit;
4206 $self->do_checkin();
4207 return if $self->bail_out;
4209 unless( $self->permit_override ) {
4211 return if $self->bail_out;
4212 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
4213 $self->remove_event('ITEM_NOT_CATALOGED');
4216 $self->override_events;
4217 return if $self->bail_out;
4220 $self->do_checkout();
4225 my( $self, $evt ) = @_;
4226 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4227 $logger->debug("circulator: removing event from list: $evt");
4228 my @events = @{$self->events};
4229 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
4234 my( $self, $evt ) = @_;
4235 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4236 return grep { $_->{textcode} eq $evt } @{$self->events};
4240 sub run_renew_permit {
4243 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
4244 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
4245 $self->editor, $self->copy, $self->editor->requestor, 1
4247 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
4250 my $results = $self->run_indb_circ_test;
4251 $self->push_events($self->matrix_test_result_events)
4252 unless $self->circ_test_success;
4256 # XXX: The primary mechanism for storing circ history is now handled
4257 # by tracking real circulation objects instead of bibs in a bucket.
4258 # However, this code is disabled by default and could be useful
4259 # some day, so may as well leave it for now.
4260 sub append_reading_list {
4264 $self->is_checkout and
4270 # verify history is globally enabled and uses the bucket mechanism
4271 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
4272 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
4274 return undef unless $htype and $htype eq 'bucket';
4276 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
4278 # verify the patron wants to retain the hisory
4279 my $setting = $e->search_actor_user_setting(
4280 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
4282 unless($setting and $setting->value) {
4287 my $bkt = $e->search_container_copy_bucket(
4288 {owner => $self->patron->id, btype => 'circ_history'})->[0];
4293 # find the next item position
4294 my $last_item = $e->search_container_copy_bucket_item(
4295 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
4296 $pos = $last_item->pos + 1 if $last_item;
4299 # create the history bucket if necessary
4300 $bkt = Fieldmapper::container::copy_bucket->new;
4301 $bkt->owner($self->patron->id);
4303 $bkt->btype('circ_history');
4305 $e->create_container_copy_bucket($bkt) or return $e->die_event;
4308 my $item = Fieldmapper::container::copy_bucket_item->new;
4310 $item->bucket($bkt->id);
4311 $item->target_copy($self->copy->id);
4314 $e->create_container_copy_bucket_item($item) or return $e->die_event;
4321 sub make_trigger_events {
4323 return unless $self->circ;
4324 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
4325 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
4326 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
4331 sub checkin_handle_lost_or_lo_now_found {
4332 my ($self, $bill_type, $is_longoverdue) = @_;
4334 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4336 $logger->debug("voiding $tag item billings");
4337 my $result = $CC->void_or_zero_bills_of_type($self->editor, $self->circ, $self->copy, $bill_type, "$tag ITEM RETURNED");
4338 $self->bail_on_events($self->editor->event) if ($result);
4341 sub checkin_handle_lost_or_lo_now_found_restore_od {
4343 my $circ_lib = shift;
4344 my $is_longoverdue = shift;
4345 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4347 # ------------------------------------------------------------------
4348 # restore those overdue charges voided when item was set to lost
4349 # ------------------------------------------------------------------
4351 my $ods = $self->editor->search_money_billing([
4353 xact => $self->circ->id,
4357 order_by => {mb => 'billing_ts desc'}
4361 $logger->debug("returning ".scalar(@$ods)." overdue charges pre-$tag");
4362 # Because actual users get up to all kinds of unexpectedness, we
4363 # only recreate up to $circ->max_fine in bills. I know you think
4364 # it wouldn't happen that bills could get created, voided, and
4365 # recreated more than once, but I guaran-damn-tee you that it will
4367 if ($ods && @$ods) {
4368 my $void_amount = 0;
4369 my $void_max = $self->circ->max_fine();
4370 # search for overdues voided the new way (aka "adjusted")
4371 my @billings = map {$_->id()} @$ods;
4372 my $voids = $self->editor->search_money_account_adjustment(
4374 billing => \@billings
4378 map {$void_amount += $_->amount()} @$voids;
4380 # if no adjustments found, assume they were voided the old way (aka "voided")
4381 for my $bill (@$ods) {
4382 if( $U->is_true($bill->voided) ) {
4383 $void_amount += $bill->amount();
4389 ($void_amount < $void_max ? $void_amount : $void_max),
4391 $ods->[0]->billing_type(),
4393 "System: $tag RETURNED - OVERDUES REINSTATED",
4394 $ods->[-1]->period_start(),
4395 $ods->[0]->period_end() # date this restoration the same as the last overdue (for possible subsequent fine generation)