1 package OpenILS::Application::Circ::Circulate;
2 use strict; use warnings;
3 use base 'OpenILS::Application';
4 use OpenSRF::EX qw(:try);
5 use OpenSRF::AppSession;
6 use OpenSRF::Utils::SettingsClient;
7 use OpenSRF::Utils::Logger qw(:logger);
8 use OpenSRF::Utils::Config;
9 use OpenILS::Const qw/:const/;
10 use OpenILS::Application::AppUtils;
12 my $U = "OpenILS::Application::AppUtils";
16 my $opac_renewal_use_circ_lib;
17 my $desk_renewal_use_circ_lib;
19 sub determine_booking_status {
20 unless (defined $booking_status) {
21 my $ses = create OpenSRF::AppSession("router");
22 $booking_status = grep {$_ eq "open-ils.booking"} @{
23 $ses->request("opensrf.router.info.class.list")->gather(1)
26 $logger->info("booking status: " . ($booking_status ? "on" : "off"));
29 return $booking_status;
35 flesh_fields => {acp => ['call_number','parts','floating'], acn => ['record']}
38 # table of cases where suppressing a system-generated copy alerts
39 # should generate an override of an old-style event
40 my %COPY_ALERT_OVERRIDES = (
41 "CLAIMSRETURNED\tCHECKOUT" => ['CIRC_CLAIMS_RETURNED'],
42 "CLAIMSRETURNED\tCHECKIN" => ['CIRC_CLAIMS_RETURNED'],
43 "LOST\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
44 "LONGOVERDUE\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
45 "MISSING\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
46 "DAMAGED\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
47 "LOST_AND_PAID\tCHECKOUT" => ['COPY_NOT_AVAILABLE', 'OPEN_CIRCULATION_EXISTS']
52 __PACKAGE__->register_method(
53 method => "run_method",
54 api_name => "open-ils.circ.checkout.permit",
56 Determines if the given checkout can occur
57 @param authtoken The login session key
58 @param params A trailing hash of named params including
59 barcode : The copy barcode,
60 patron : The patron the checkout is occurring for,
61 renew : true or false - whether or not this is a renewal
62 @return The event that occurred during the permit check.
66 __PACKAGE__->register_method (
67 method => 'run_method',
68 api_name => 'open-ils.circ.checkout.permit.override',
69 signature => q/@see open-ils.circ.checkout.permit/,
73 __PACKAGE__->register_method(
74 method => "run_method",
75 api_name => "open-ils.circ.checkout",
78 @param authtoken The login session key
79 @param params A named hash of params including:
81 barcode If no copy is provided, the copy is retrieved via barcode
82 copyid If no copy or barcode is provide, the copy id will be use
83 patron The patron's id
84 noncat True if this is a circulation for a non-cataloted item
85 noncat_type The non-cataloged type id
86 noncat_circ_lib The location for the noncat circ.
87 precat The item has yet to be cataloged
88 dummy_title The temporary title of the pre-cataloded item
89 dummy_author The temporary authr of the pre-cataloded item
90 Default is the home org of the staff member
91 @return The SUCCESS event on success, any other event depending on the error
94 __PACKAGE__->register_method(
95 method => "run_method",
96 api_name => "open-ils.circ.checkin",
99 Generic super-method for handling all copies
100 @param authtoken The login session key
101 @param params Hash of named parameters including:
102 barcode - The copy barcode
103 force - If true, copies in bad statuses will be checked in and give good statuses
104 noop - don't capture holds or put items into transit
105 void_overdues - void all overdues for the circulation (aka amnesty)
110 __PACKAGE__->register_method(
111 method => "run_method",
112 api_name => "open-ils.circ.checkin.override",
113 signature => q/@see open-ils.circ.checkin/
116 __PACKAGE__->register_method(
117 method => "run_method",
118 api_name => "open-ils.circ.renew.override",
119 signature => q/@see open-ils.circ.renew/,
122 __PACKAGE__->register_method(
123 method => "run_method",
124 api_name => "open-ils.circ.renew.auto",
125 signature => q/@see open-ils.circ.renew/,
127 The open-ils.circ.renew.auto API is deprecated. Please use the
128 auto_renew => 1 option to open-ils.circ.renew, instead.
132 __PACKAGE__->register_method(
133 method => "run_method",
134 api_name => "open-ils.circ.renew",
135 notes => <<" NOTES");
136 PARAMS( authtoken, circ => circ_id );
137 open-ils.circ.renew(login_session, circ_object);
138 Renews the provided circulation. login_session is the requestor of the
139 renewal and if the logged in user is not the same as circ->usr, then
140 the logged in user must have RENEW_CIRC permissions.
143 __PACKAGE__->register_method(
144 method => "run_method",
145 api_name => "open-ils.circ.checkout.full"
147 __PACKAGE__->register_method(
148 method => "run_method",
149 api_name => "open-ils.circ.checkout.full.override"
151 __PACKAGE__->register_method(
152 method => "run_method",
153 api_name => "open-ils.circ.reservation.pickup"
155 __PACKAGE__->register_method(
156 method => "run_method",
157 api_name => "open-ils.circ.reservation.return"
159 __PACKAGE__->register_method(
160 method => "run_method",
161 api_name => "open-ils.circ.reservation.return.override"
163 __PACKAGE__->register_method(
164 method => "run_method",
165 api_name => "open-ils.circ.checkout.inspect",
166 desc => q/Returns the circ matrix test result and, on success, the rule set and matrix test object/
171 my( $self, $conn, $auth, $args ) = @_;
172 translate_legacy_args($args);
173 $args->{override_args} = { all => 1 } unless defined $args->{override_args};
174 $args->{new_copy_alerts} ||= $self->api_level > 1 ? 1 : 0;
175 my $api = $self->api_name;
178 OpenILS::Application::Circ::Circulator->new($auth, %$args);
180 return circ_events($circulator) if $circulator->bail_out;
182 $circulator->use_booking(determine_booking_status());
184 # --------------------------------------------------------------------------
185 # First, check for a booking transit, as the barcode may not be a copy
186 # barcode, but a resource barcode, and nothing else in here will work
187 # --------------------------------------------------------------------------
189 if ($circulator->use_booking && (my $bc = $circulator->copy_barcode) && $api !~ /checkout|inspect/) { # do we have a barcode?
190 my $resources = $circulator->editor->search_booking_resource( { barcode => $bc } ); # any resources by this barcode?
191 if (@$resources) { # yes!
193 my $res_id_list = [ map { $_->id } @$resources ];
194 my $transit = $circulator->editor->search_action_reservation_transit_copy(
196 { target_copy => $res_id_list, dest => $circulator->circ_lib, dest_recv_time => undef, cancel_time => undef },
197 { order_by => { artc => 'source_send_time' }, limit => 1 }
199 )->[0]; # Any transit for this barcode?
201 if ($transit) { # yes! unwrap it.
203 my $reservation = $circulator->editor->retrieve_booking_reservation( $transit->reservation );
204 my $res_type = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
206 my $success_event = new OpenILS::Event(
207 "SUCCESS", "payload" => {"reservation" => $reservation}
209 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
210 if (my $copy = $circulator->editor->search_asset_copy([
211 { barcode => $bc, deleted => 'f' }, $MK_ENV_FLESH
212 ])->[0]) { # got a copy
213 $copy->status( $transit->copy_status );
214 $copy->editor($circulator->editor->requestor->id);
215 $copy->edit_date('now');
216 $circulator->editor->update_asset_copy($copy);
217 $success_event->{"payload"}->{"record"} =
218 $U->record_to_mvr($copy->call_number->record);
219 $success_event->{"payload"}->{"volume"} = $copy->call_number;
220 $copy->call_number($copy->call_number->id);
221 $success_event->{"payload"}->{"copy"} = $copy;
225 $transit->dest_recv_time('now');
226 $circulator->editor->update_action_reservation_transit_copy( $transit );
228 $circulator->editor->commit;
229 # Formerly this branch just stopped here. Argh!
230 $conn->respond_complete($success_event);
236 if ($circulator->use_booking) {
237 $circulator->is_res_checkin($circulator->is_checkin(1))
238 if $api =~ /reservation.return/ or (
239 $api =~ /checkin/ and $circulator->seems_like_reservation()
242 $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
245 $circulator->is_renewal(1) if $api =~ /renew/;
246 $circulator->auto_renewal(1) if $api =~ /renew.auto/;
247 $circulator->is_checkin(1) if $api =~ /checkin/;
248 $circulator->is_checkout(1) if $api =~ /checkout/;
249 $circulator->override(1) if $api =~ /override/o;
251 $circulator->mk_env();
252 $circulator->noop(1) if $circulator->claims_never_checked_out;
254 return circ_events($circulator) if $circulator->bail_out;
256 if( $api =~ /checkout\.permit/ ) {
257 $circulator->do_permit();
259 } elsif( $api =~ /checkout.full/ ) {
261 # requesting a precat checkout implies that any required
262 # overrides have been performed. Go ahead and re-override.
263 $circulator->skip_permit_key(1);
264 $circulator->override(1) if ( $circulator->request_precat && $circulator->editor->allowed('CREATE_PRECAT') );
265 $circulator->do_permit();
266 $circulator->is_checkout(1);
267 unless( $circulator->bail_out ) {
268 $circulator->events([]);
269 $circulator->do_checkout();
272 } elsif( $circulator->is_res_checkout ) {
273 $circulator->do_reservation_pickup();
275 } elsif( $api =~ /inspect/ ) {
276 my $data = $circulator->do_inspect();
277 $circulator->editor->rollback;
280 } elsif( $api =~ /checkout/ ) {
281 $circulator->do_checkout();
283 } elsif( $circulator->is_res_checkin ) {
284 $circulator->do_reservation_return();
285 $circulator->do_checkin() if ($circulator->copy());
286 } elsif( $api =~ /checkin/ ) {
287 $circulator->do_checkin();
289 } elsif( $api =~ /renew/ ) {
290 $circulator->do_renew($api);
293 if( $circulator->bail_out ) {
296 # make sure no success event accidentally slip in
298 [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
301 my @e = @{$circulator->events};
302 push( @ee, $_->{textcode} ) for @e;
303 $logger->info("circulator: bailing out with events: " . (join ", ", @ee));
305 $circulator->editor->rollback;
309 # checkin and reservation return can result in modifications to
310 # actor.usr.claims_never_checked_out_count without also modifying
311 # actor.last_xact_id. Perform a no-op update on the patron to
312 # force an update to last_xact_id.
313 if ($circulator->claims_never_checked_out && $circulator->patron) {
314 $circulator->editor->update_actor_user(
315 $circulator->editor->retrieve_actor_user($circulator->patron->id))
316 or return $circulator->editor->die_event;
319 $circulator->editor->commit;
322 $conn->respond_complete(circ_events($circulator));
324 return undef if $circulator->bail_out;
326 $circulator->do_hold_notify($circulator->notify_hold)
327 if $circulator->notify_hold;
328 $circulator->retarget_holds if $circulator->retarget;
329 $circulator->append_reading_list;
330 $circulator->make_trigger_events;
337 my @e = @{$circ->events};
338 # if we have multiple events, SUCCESS should not be one of them;
339 @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
340 return (@e == 1) ? $e[0] : \@e;
344 sub translate_legacy_args {
347 if( $$args{barcode} ) {
348 $$args{copy_barcode} = $$args{barcode};
349 delete $$args{barcode};
352 if( $$args{copyid} ) {
353 $$args{copy_id} = $$args{copyid};
354 delete $$args{copyid};
357 if( $$args{patronid} ) {
358 $$args{patron_id} = $$args{patronid};
359 delete $$args{patronid};
362 if( $$args{patron} and !ref($$args{patron}) ) {
363 $$args{patron_id} = $$args{patron};
364 delete $$args{patron};
368 if( $$args{noncat} ) {
369 $$args{is_noncat} = $$args{noncat};
370 delete $$args{noncat};
373 if( $$args{precat} ) {
374 $$args{is_precat} = $$args{request_precat} = $$args{precat};
375 delete $$args{precat};
381 # --------------------------------------------------------------------------
382 # This package actually manages all of the circulation logic
383 # --------------------------------------------------------------------------
384 package OpenILS::Application::Circ::Circulator;
385 use strict; use warnings;
386 use vars q/$AUTOLOAD/;
388 use OpenILS::Utils::Fieldmapper;
389 use OpenSRF::Utils::Cache;
390 use Digest::MD5 qw(md5_hex);
391 use DateTime::Format::ISO8601;
392 use OpenILS::Utils::PermitHold;
393 use OpenILS::Utils::DateTime qw/:datetime/;
394 use OpenSRF::Utils::SettingsClient;
395 use OpenILS::Application::Circ::Holds;
396 use OpenILS::Application::Circ::Transit;
397 use OpenSRF::Utils::Logger qw(:logger);
398 use OpenILS::Utils::CStoreEditor qw/:funcs/;
399 use OpenILS::Const qw/:const/;
400 use OpenILS::Utils::Penalty;
401 use OpenILS::Application::Circ::CircCommon;
404 my $CC = "OpenILS::Application::Circ::CircCommon";
405 my $holdcode = "OpenILS::Application::Circ::Holds";
406 my $transcode = "OpenILS::Application::Circ::Transit";
412 # --------------------------------------------------------------------------
413 # Add a pile of automagic getter/setter methods
414 # --------------------------------------------------------------------------
415 my @AUTOLOAD_FIELDS = qw/
428 overrides_per_copy_alerts
469 recurring_fines_level
474 auto_renewal_remaining
483 cancelled_hold_transit
491 circ_matrix_matchpoint
502 claims_never_checked_out
515 dont_change_lost_zero
517 needs_lost_bill_handling
523 my $type = ref($self) or die "$self is not an object";
525 my $name = $AUTOLOAD;
528 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
529 $logger->error("circulator: $type: invalid autoload field: $name");
530 die "$type: invalid autoload field: $name\n"
535 *{"${type}::${name}"} = sub {
538 $s->{$name} = $v if defined $v;
542 return $self->$name($data);
547 my( $class, $auth, %args ) = @_;
548 $class = ref($class) || $class;
549 my $self = bless( {}, $class );
552 $self->editor(new_editor(xact => 1, authtoken => $auth));
554 unless( $self->editor->checkauth ) {
555 $self->bail_on_events($self->editor->event);
559 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
561 $self->$_($args{$_}) for keys %args;
564 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
566 # if this is a renewal, default to desk_renewal
567 $self->desk_renewal(1) unless
568 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal
569 or $self->auto_renewal;
571 $self->capture('') unless $self->capture;
573 unless(%user_groups) {
574 my $gps = $self->editor->retrieve_all_permission_grp_tree;
575 %user_groups = map { $_->id => $_ } @$gps;
582 # --------------------------------------------------------------------------
583 # True if we should discontinue processing
584 # --------------------------------------------------------------------------
586 my( $self, $bool ) = @_;
587 if( defined $bool ) {
588 $logger->info("circulator: BAILING OUT") if $bool;
589 $self->{bail_out} = $bool;
591 return $self->{bail_out};
596 my( $self, @evts ) = @_;
599 $e->{payload} = $self->copy if
600 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
602 $logger->info("circulator: pushing event ".$e->{textcode});
603 push( @{$self->events}, $e ) unless
604 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
610 return '' if $self->skip_permit_key;
611 my $key = md5_hex( time() . rand() . "$$" );
612 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
613 return $self->permit_key($key);
616 sub check_permit_key {
618 return 1 if $self->skip_permit_key;
619 my $key = $self->permit_key;
620 return 0 unless $key;
621 my $k = "oils_permit_key_$key";
622 my $one = $self->cache_handle->get_cache($k);
623 $self->cache_handle->delete_cache($k);
624 return ($one) ? 1 : 0;
627 sub seems_like_reservation {
630 # Some words about the following method:
631 # 1) It requires the VIEW_USER permission, but that's not an
632 # issue, right, since all staff should have that?
633 # 2) It returns only one reservation at a time, even if an item can be
634 # and is currently overbooked. Hmmm....
635 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
636 my $result = $booking_ses->request(
637 "open-ils.booking.reservations.by_returnable_resource_barcode",
638 $self->editor->authtoken,
641 $booking_ses->disconnect;
643 return $self->bail_on_events($result) if defined $U->event_code($result);
646 $self->reservation(shift @$result);
654 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
655 sub save_trimmed_copy {
656 my ($self, $copy) = @_;
659 $self->volume($copy->call_number);
660 $self->title($self->volume->record);
661 $self->copy->call_number($self->volume->id);
662 $self->volume->record($self->title->id);
663 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
664 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
665 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
666 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
670 sub collect_user_copy_alerts {
672 my $e = $self->editor;
675 my $alerts = $e->search_asset_copy_alert([
676 {copy => $self->copy->id, ack_time => undef},
677 {flesh => 1, flesh_fields => { aca => [ qw/ alert_type / ] }}
679 if (ref $alerts eq "ARRAY") {
680 $logger->info("circulator: found " . scalar(@$alerts) . " alerts for copy " .
682 $self->user_copy_alerts($alerts);
687 sub filter_user_copy_alerts {
690 my $e = $self->editor;
692 if(my $alerts = $self->user_copy_alerts) {
694 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
695 my $suppressions = $e->search_actor_copy_alert_suppress(
696 {org => $suppress_orgs}
700 foreach my $a (@$alerts) {
701 # filter on event type
702 if (defined $a->alert_type) {
703 next if ($a->alert_type->event eq 'CHECKIN' && !$self->is_checkin && !$self->is_renewal);
704 next if ($a->alert_type->event eq 'CHECKOUT' && !$self->is_checkout && !$self->is_renewal);
705 next if (defined $a->alert_type->in_renew && $U->is_true($a->alert_type->in_renew) && !$self->is_renewal);
706 next if (defined $a->alert_type->in_renew && !$U->is_true($a->alert_type->in_renew) && $self->is_renewal);
709 # filter on suppression
710 next if (grep { $a->alert_type->id == $_->alert_type} @$suppressions);
712 # filter on "only at circ lib"
713 if (defined $a->alert_type->at_circ) {
714 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
715 $self->copy->circ_lib->id : $self->copy->circ_lib;
716 my $orgs = $U->get_org_descendants($copy_circ_lib);
718 if ($U->is_true($a->alert_type->invert_location)) {
719 next if (grep {$_ == $self->circ_lib} @$orgs);
721 next unless (grep {$_ == $self->circ_lib} @$orgs);
725 # filter on "only at owning lib"
726 if (defined $a->alert_type->at_owning) {
727 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
728 $self->volume->owning_lib->id : $self->volume->owning_lib;
729 my $orgs = $U->get_org_descendants($copy_owning_lib);
731 if ($U->is_true($a->alert_type->invert_location)) {
732 next if (grep {$_ == $self->circ_lib} @$orgs);
734 next unless (grep {$_ == $self->circ_lib} @$orgs);
738 $a->alert_type->next_status([$U->unique_unnested_numbers($a->alert_type->next_status)]);
740 push @final_alerts, $a;
743 $self->user_copy_alerts(\@final_alerts);
747 sub generate_system_copy_alerts {
749 return unless($self->copy);
751 # don't create system copy alerts if the copy
752 # is in a normal state; we're assuming that there's
753 # never a need to generate a popup for each and every
754 # checkin or checkout of normal items. If this assumption
755 # proves false, then we'll need to add a way to explicitly specify
756 # that a copy alert type should never generate a system copy alert
757 return if $self->copy_state eq 'NORMAL';
759 my $e = $self->editor;
761 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
762 my $suppressions = $e->search_actor_copy_alert_suppress(
763 {org => $suppress_orgs}
766 # events we care about ...
768 push(@$event, 'CHECKIN') if $self->is_checkin;
769 push(@$event, 'CHECKOUT') if $self->is_checkout;
770 return unless scalar(@$event);
772 my $alert_orgs = $U->get_org_ancestors($self->circ_lib);
773 my $alert_types = $e->search_config_copy_alert_type({
775 scope_org => $alert_orgs,
777 state => $self->copy_state,
778 '-or' => [ { in_renew => $self->is_renewal }, { in_renew => undef } ],
782 foreach my $a (@$alert_types) {
783 # filter on "only at circ lib"
784 if (defined $a->at_circ) {
785 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
786 $self->copy->circ_lib->id : $self->copy->circ_lib;
787 my $orgs = $U->get_org_descendants($copy_circ_lib);
789 if ($U->is_true($a->invert_location)) {
790 next if (grep {$_ == $self->circ_lib} @$orgs);
792 next unless (grep {$_ == $self->circ_lib} @$orgs);
796 # filter on "only at owning lib"
797 if (defined $a->at_owning) {
798 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
799 $self->volume->owning_lib->id : $self->volume->owning_lib;
800 my $orgs = $U->get_org_descendants($copy_owning_lib);
802 if ($U->is_true($a->invert_location)) {
803 next if (grep {$_ == $self->circ_lib} @$orgs);
805 next unless (grep {$_ == $self->circ_lib} @$orgs);
809 push @final_types, $a;
813 $logger->info("circulator: found " . scalar(@final_types) . " system alert types for copy" .
819 # keep track of conditions corresponding to suppressed
820 # system alerts, as these may be used to overridee
821 # certain old-style-events
822 my %auto_override_conditions = ();
823 foreach my $t (@final_types) {
824 if ($t->next_status) {
825 if (grep { $t->id == $_->alert_type } @$suppressions) {
828 $t->next_status([$U->unique_unnested_numbers($t->next_status)]);
832 my $alert = new Fieldmapper::asset::copy_alert ();
833 $alert->alert_type($t->id);
834 $alert->copy($self->copy->id);
836 $alert->create_staff($e->requestor->id);
837 $alert->create_time('now');
838 $alert->ack_staff($e->requestor->id);
839 $alert->ack_time('now');
841 $alert = $e->create_asset_copy_alert($alert);
845 $alert->alert_type($t->clone);
847 push(@{$self->next_copy_status}, @{$t->next_status}) if ($t->next_status);
848 if (grep {$_->alert_type == $t->id} @$suppressions) {
849 $auto_override_conditions{join("\t", $t->state, $t->event)} = 1;
851 push(@alerts, $alert) unless (grep {$_->alert_type == $t->id} @$suppressions);
854 $self->system_copy_alerts(\@alerts);
855 $self->overrides_per_copy_alerts(\%auto_override_conditions);
858 sub add_overrides_from_system_copy_alerts {
860 my $e = $self->editor;
862 foreach my $condition (keys %{$self->overrides_per_copy_alerts()}) {
863 if (exists $COPY_ALERT_OVERRIDES{$condition}) {
865 push @{$self->override_args->{events}}, @{ $COPY_ALERT_OVERRIDES{$condition} };
866 # special handling for long-overdue and lost checkouts
867 if (grep { $_ eq 'OPEN_CIRCULATION_EXISTS' } @{ $COPY_ALERT_OVERRIDES{$condition} }) {
868 my $state = (split /\t/, $condition, -1)[0];
870 if ($state eq 'LOST' or $state eq 'LOST_AND_PAID') {
871 $setting = 'circ.copy_alerts.forgive_fines_on_lost_checkin';
872 } elsif ($state eq 'LONGOVERDUE') {
873 $setting = 'circ.copy_alerts.forgive_fines_on_long_overdue_checkin';
877 my $forgive = $U->ou_ancestor_setting_value(
878 $self->circ_lib, $setting, $e
880 if ($U->is_true($forgive)) {
881 $self->void_overdues(1);
883 $self->noop(1); # do not attempt transits, just check it in
892 my $e = $self->editor;
894 $self->next_copy_status([]) unless (defined $self->next_copy_status);
895 $self->overrides_per_copy_alerts({}) unless (defined $self->overrides_per_copy_alerts);
897 # --------------------------------------------------------------------------
898 # Grab the fleshed copy
899 # --------------------------------------------------------------------------
900 unless($self->is_noncat) {
903 $copy = $e->retrieve_asset_copy(
904 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
906 } elsif( $self->copy_barcode ) {
908 $copy = $e->search_asset_copy(
909 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
910 } elsif( $self->reservation ) {
911 my $res = $e->json_query(
913 "select" => {"acp" => ["id"]},
918 "field" => "barcode",
922 "field" => "current_resource"
931 "id" => (ref $self->reservation) ?
932 $self->reservation->id : $self->reservation
937 if (ref $res eq "ARRAY" and scalar @$res) {
938 $logger->info("circulator: mapped reservation " .
939 $self->reservation . " to copy " . $res->[0]->{"id"});
940 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
945 $self->save_trimmed_copy($copy);
950 {from => ['asset.copy_state', $copy->id]}
951 )->[0]{'asset.copy_state'}
954 $self->generate_system_copy_alerts;
955 $self->add_overrides_from_system_copy_alerts;
956 $self->collect_user_copy_alerts;
957 $self->filter_user_copy_alerts;
960 # We can't renew if there is no copy
961 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
962 if $self->is_renewal;
967 # --------------------------------------------------------------------------
969 # --------------------------------------------------------------------------
973 flesh_fields => {au => [ qw/ card / ]}
976 if( $self->patron_id ) {
977 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
978 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
980 } elsif( $self->patron_barcode ) {
982 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
983 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
984 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
986 $patron = $e->retrieve_actor_user($card->usr)
987 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
989 # Use the card we looked up, not the patron's primary, for card active checks
990 $patron->card($card);
993 if( my $copy = $self->copy ) {
996 $flesh->{flesh_fields}->{circ} = ['usr'];
998 my $circ = $e->search_action_circulation([
999 {target_copy => $copy->id, checkin_time => undef}, $flesh
1003 $patron = $circ->usr;
1004 $circ->usr($patron->id); # de-flesh for consistency
1010 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
1011 unless $self->patron($patron) or $self->is_checkin;
1013 unless($self->is_checkin) {
1015 # Check for inactivity and patron reg. expiration
1017 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
1018 unless $U->is_true($patron->active);
1020 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
1021 unless $U->is_true($patron->card->active);
1023 # Expired patrons cannot check out. Renewals for expired
1024 # patrons depend on a setting and will be checked in the
1025 # do_renew subroutine.
1026 if ($self->is_checkout) {
1027 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1028 clean_ISO8601($patron->expire_date));
1030 if (CORE::time > $expire->epoch) {
1031 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1038 # --------------------------------------------------------------------------
1039 # Does the circ permit work
1040 # --------------------------------------------------------------------------
1044 $self->log_me("do_permit()");
1046 unless( $self->editor->requestor->id == $self->patron->id ) {
1047 return $self->bail_on_events($self->editor->event)
1048 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1051 $self->check_captured_holds();
1052 $self->do_copy_checks();
1053 return if $self->bail_out;
1054 $self->run_patron_permit_scripts();
1055 $self->run_copy_permit_scripts()
1056 unless $self->is_precat or $self->is_noncat;
1057 $self->check_item_deposit_events();
1058 $self->override_events();
1059 return if $self->bail_out;
1061 if($self->is_precat and not $self->request_precat) {
1063 OpenILS::Event->new(
1064 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1065 return $self->bail_out(1) unless $self->is_renewal;
1069 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1072 sub check_item_deposit_events {
1074 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
1075 if $self->is_deposit and not $self->is_deposit_exempt;
1076 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
1077 if $self->is_rental and not $self->is_rental_exempt;
1080 # returns true if the user is not required to pay deposits
1081 sub is_deposit_exempt {
1083 my $pid = (ref $self->patron->profile) ?
1084 $self->patron->profile->id : $self->patron->profile;
1085 my $groups = $U->ou_ancestor_setting_value(
1086 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1087 for my $grp (@$groups) {
1088 return 1 if $self->is_group_descendant($grp, $pid);
1093 # returns true if the user is not required to pay rental fees
1094 sub is_rental_exempt {
1096 my $pid = (ref $self->patron->profile) ?
1097 $self->patron->profile->id : $self->patron->profile;
1098 my $groups = $U->ou_ancestor_setting_value(
1099 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1100 for my $grp (@$groups) {
1101 return 1 if $self->is_group_descendant($grp, $pid);
1106 sub is_group_descendant {
1107 my($self, $p_id, $c_id) = @_;
1108 return 0 unless defined $p_id and defined $c_id;
1109 return 1 if $c_id == $p_id;
1110 while(my $grp = $user_groups{$c_id}) {
1111 $c_id = $grp->parent;
1112 return 0 unless defined $c_id;
1113 return 1 if $c_id == $p_id;
1118 sub check_captured_holds {
1120 my $copy = $self->copy;
1121 my $patron = $self->patron;
1123 return undef unless $copy;
1125 my $s = $U->copy_status($copy->status)->id;
1126 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1127 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1129 # Item is on the holds shelf, make sure it's going to the right person
1130 my $hold = $self->editor->search_action_hold_request(
1133 current_copy => $copy->id ,
1134 capture_time => { '!=' => undef },
1135 cancel_time => undef,
1136 fulfillment_time => undef
1140 flesh_fields => { ahr => ['usr'] }
1145 if ($hold and $hold->usr->id == $patron->id) {
1146 $self->checkout_is_for_hold(1);
1150 my $holdau = $hold->usr;
1153 $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1154 $payload->{patron_id} = $holdau->id;
1156 $payload->{patron_name} = "???";
1158 $payload->{hold_id} = $hold->id;
1159 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1160 payload => $payload));
1163 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1168 sub do_copy_checks {
1170 my $copy = $self->copy;
1171 return unless $copy;
1173 my $stat = $U->copy_status($copy->status)->id;
1175 # We cannot check out a copy if it is in-transit
1176 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1177 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1180 $self->handle_claims_returned();
1181 return if $self->bail_out;
1183 # no claims returned circ was found, check if there is any open circ
1184 unless( $self->is_renewal ) {
1186 my $circs = $self->editor->search_action_circulation(
1187 { target_copy => $copy->id, checkin_time => undef }
1190 if(my $old_circ = $circs->[0]) { # an open circ was found
1192 my $payload = {copy => $copy};
1194 if($old_circ->usr == $self->patron->id) {
1196 $payload->{old_circ} = $old_circ;
1198 # If there is an open circulation on the checkout item and an auto-renew
1199 # interval is defined, inform the caller that they should go
1200 # ahead and renew the item instead of warning about open circulations.
1202 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1204 'circ.checkout_auto_renew_age',
1208 if($auto_renew_intvl) {
1209 my $intvl_seconds = OpenILS::Utils::DateTime->interval_to_seconds($auto_renew_intvl);
1210 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( clean_ISO8601($old_circ->xact_start) );
1212 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1213 $payload->{auto_renew} = 1;
1218 return $self->bail_on_events(
1219 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1225 my $LEGACY_CIRC_EVENT_MAP = {
1226 'no_item' => 'ITEM_NOT_CATALOGED',
1227 'actor.usr.barred' => 'PATRON_BARRED',
1228 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1229 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1230 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1231 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1232 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1233 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1234 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1235 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1236 'config.circ_matrix_test.total_copy_hold_ratio' =>
1237 'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1238 'config.circ_matrix_test.available_copy_hold_ratio' =>
1239 'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1243 # ---------------------------------------------------------------------
1244 # This pushes any patron-related events into the list but does not
1245 # set bail_out for any events
1246 # ---------------------------------------------------------------------
1247 sub run_patron_permit_scripts {
1249 my $patronid = $self->patron->id;
1254 my $results = $self->run_indb_circ_test;
1255 unless($self->circ_test_success) {
1256 my @trimmed_results;
1258 if ($self->is_noncat) {
1259 # no_item result is OK during noncat checkout
1260 @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1264 if ($self->checkout_is_for_hold) {
1265 # if this checkout will fulfill a hold, ignore CIRC blocks
1266 # and rely instead on the (later-checked) FULFILL block
1268 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1269 my $fblock_pens = $self->editor->search_config_standing_penalty(
1270 {name => [@pen_names], block_list => {like => '%CIRC%'}});
1272 for my $res (@$results) {
1273 my $name = $res->{fail_part} || '';
1274 next if grep {$_->name eq $name} @$fblock_pens;
1275 push(@trimmed_results, $res);
1279 # not for hold or noncat
1280 @trimmed_results = @$results;
1284 # update the final set of test results
1285 $self->matrix_test_result(\@trimmed_results);
1287 push @allevents, $self->matrix_test_result_events;
1291 $_->{payload} = $self->copy if
1292 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1295 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1297 $self->push_events(@allevents);
1300 sub matrix_test_result_codes {
1302 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1305 sub matrix_test_result_events {
1308 my $event = new OpenILS::Event(
1309 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1311 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1313 } (@{$self->matrix_test_result});
1316 sub run_indb_circ_test {
1318 return $self->matrix_test_result if $self->matrix_test_result;
1320 my $dbfunc = ($self->is_renewal) ?
1321 'action.item_user_renew_test' : 'action.item_user_circ_test';
1323 if( $self->is_precat && $self->request_precat) {
1324 $self->make_precat_copy;
1325 return if $self->bail_out;
1328 my $results = $self->editor->json_query(
1332 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1338 $self->circ_test_success($U->is_true($results->[0]->{success}));
1340 if(my $mp = $results->[0]->{matchpoint}) {
1341 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1342 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1343 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1344 if(defined($results->[0]->{renewals})) {
1345 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1347 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1348 if(defined($results->[0]->{grace_period})) {
1349 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1351 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1352 if(defined($results->[0]->{hard_due_date})) {
1353 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1355 # Grab the *last* response for limit_groups, where it is more likely to be filled
1356 $self->limit_groups($results->[-1]->{limit_groups});
1359 return $self->matrix_test_result($results);
1362 # ---------------------------------------------------------------------
1363 # given a use and copy, this will calculate the circulation policy
1364 # parameters. Only works with in-db circ.
1365 # ---------------------------------------------------------------------
1369 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1371 $self->run_indb_circ_test;
1374 circ_test_success => $self->circ_test_success,
1375 failure_events => [],
1376 failure_codes => [],
1377 matchpoint => $self->circ_matrix_matchpoint
1380 unless($self->circ_test_success) {
1381 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1382 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1385 if($self->circ_matrix_matchpoint) {
1386 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1387 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1388 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1389 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1391 my $policy = $self->get_circ_policy(
1392 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1394 $$results{$_} = $$policy{$_} for keys %$policy;
1400 # ---------------------------------------------------------------------
1401 # Loads the circ policy info for duration, recurring fine, and max
1402 # fine based on the current copy
1403 # ---------------------------------------------------------------------
1404 sub get_circ_policy {
1405 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1408 duration_rule => $duration_rule->name,
1409 recurring_fine_rule => $recurring_fine_rule->name,
1410 max_fine_rule => $max_fine_rule->name,
1411 max_fine => $self->get_max_fine_amount($max_fine_rule),
1412 fine_interval => $recurring_fine_rule->recurrence_interval,
1413 renewal_remaining => $duration_rule->max_renewals,
1414 auto_renewal_remaining => $duration_rule->max_auto_renewals,
1415 grace_period => $recurring_fine_rule->grace_period
1418 if($hard_due_date) {
1419 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1420 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1423 $policy->{duration_date_ceiling} = undef;
1424 $policy->{duration_date_ceiling_force} = undef;
1427 $policy->{duration} = $duration_rule->shrt
1428 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1429 $policy->{duration} = $duration_rule->normal
1430 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1431 $policy->{duration} = $duration_rule->extended
1432 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1434 $policy->{recurring_fine} = $recurring_fine_rule->low
1435 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1436 $policy->{recurring_fine} = $recurring_fine_rule->normal
1437 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1438 $policy->{recurring_fine} = $recurring_fine_rule->high
1439 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1444 sub get_max_fine_amount {
1446 my $max_fine_rule = shift;
1447 my $max_amount = $max_fine_rule->amount;
1449 # if is_percent is true then the max->amount is
1450 # use as a percentage of the copy price
1451 if ($U->is_true($max_fine_rule->is_percent)) {
1452 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1453 $max_amount = $price * $max_fine_rule->amount / 100;
1455 $U->ou_ancestor_setting_value(
1457 'circ.max_fine.cap_at_price',
1461 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1462 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1470 sub run_copy_permit_scripts {
1472 my $copy = $self->copy || return;
1476 my $results = $self->run_indb_circ_test;
1477 push @allevents, $self->matrix_test_result_events
1478 unless $self->circ_test_success;
1480 # See if this copy has an alert message
1481 my $ae = $self->check_copy_alert();
1482 push( @allevents, $ae ) if $ae;
1484 # uniquify the events
1485 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1486 @allevents = values %hash;
1488 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1490 $self->push_events(@allevents);
1494 sub check_copy_alert {
1497 if ($self->new_copy_alerts) {
1499 push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts
1500 if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1502 push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts
1503 if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1506 $self->bail_out(1) if (!$self->override);
1507 return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1511 return undef if $self->is_renewal;
1512 return OpenILS::Event->new(
1513 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1514 if $self->copy and $self->copy->alert_message;
1520 # --------------------------------------------------------------------------
1521 # If the call is overriding and has permissions to override every collected
1522 # event, the are cleared. Any event that the caller does not have
1523 # permission to override, will be left in the event list and bail_out will
1525 # XXX We need code in here to cancel any holds/transits on copies
1526 # that are being force-checked out
1527 # --------------------------------------------------------------------------
1528 sub override_events {
1530 my @events = @{$self->events};
1531 return unless @events;
1532 my $oargs = $self->override_args;
1534 if(!$self->override) {
1535 return $self->bail_out(1)
1536 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1541 for my $e (@events) {
1542 my $tc = $e->{textcode};
1543 next if $tc eq 'SUCCESS';
1544 if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1545 my $ov = "$tc.override";
1546 $logger->info("circulator: attempting to override event: $ov");
1548 return $self->bail_on_events($self->editor->event)
1549 unless( $self->editor->allowed($ov) );
1551 return $self->bail_out(1);
1557 # --------------------------------------------------------------------------
1558 # If there is an open claimsreturn circ on the requested copy, close the
1559 # circ if overriding, otherwise bail out
1560 # --------------------------------------------------------------------------
1561 sub handle_claims_returned {
1563 my $copy = $self->copy;
1565 my $CR = $self->editor->search_action_circulation(
1567 target_copy => $copy->id,
1568 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1569 checkin_time => undef,
1573 return unless ($CR = $CR->[0]);
1577 # - If the caller has set the override flag, we will check the item in
1578 if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1580 $CR->checkin_time('now');
1581 $CR->checkin_scan_time('now');
1582 $CR->checkin_lib($self->circ_lib);
1583 $CR->checkin_workstation($self->editor->requestor->wsid);
1584 $CR->checkin_staff($self->editor->requestor->id);
1586 $evt = $self->editor->event
1587 unless $self->editor->update_action_circulation($CR);
1590 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1593 $self->bail_on_events($evt) if $evt;
1598 # --------------------------------------------------------------------------
1599 # This performs the checkout
1600 # --------------------------------------------------------------------------
1604 $self->log_me("do_checkout()");
1606 # make sure perms are good if this isn't a renewal
1607 unless( $self->is_renewal ) {
1608 return $self->bail_on_events($self->editor->event)
1609 unless( $self->editor->allowed('COPY_CHECKOUT') );
1612 # verify the permit key
1613 unless( $self->check_permit_key ) {
1614 if( $self->permit_override ) {
1615 return $self->bail_on_events($self->editor->event)
1616 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1618 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1622 # if this is a non-cataloged circ, build the circ and finish
1623 if( $self->is_noncat ) {
1624 $self->checkout_noncat;
1626 OpenILS::Event->new('SUCCESS',
1627 payload => { noncat_circ => $self->circ }));
1631 if( $self->is_precat ) {
1632 $self->make_precat_copy;
1633 return if $self->bail_out;
1635 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1636 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1639 $self->do_copy_checks;
1640 return if $self->bail_out;
1642 $self->run_checkout_scripts();
1643 return if $self->bail_out;
1645 $self->build_checkout_circ_object();
1646 return if $self->bail_out;
1648 my $modify_to_start = $self->booking_adjusted_due_date();
1649 return if $self->bail_out;
1651 $self->apply_modified_due_date($modify_to_start);
1652 return if $self->bail_out;
1654 return $self->bail_on_events($self->editor->event)
1655 unless $self->editor->create_action_circulation($self->circ);
1657 # refresh the circ to force local time zone for now
1658 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1660 if($self->limit_groups) {
1661 $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1664 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1666 return if $self->bail_out;
1668 $self->apply_deposit_fee();
1669 return if $self->bail_out;
1671 $self->handle_checkout_holds();
1672 return if $self->bail_out;
1674 # ------------------------------------------------------------------------------
1675 # Update the patron penalty info in the DB. Run it for permit-overrides
1676 # since the penalties are not updated during the permit phase
1677 # ------------------------------------------------------------------------------
1678 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1680 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1683 if($self->is_renewal) {
1684 # flesh the billing summary for the checked-in circ
1685 $pcirc = $self->editor->retrieve_action_circulation([
1687 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1692 OpenILS::Event->new('SUCCESS',
1694 copy => $U->unflesh_copy($self->copy),
1695 volume => $self->volume,
1696 circ => $self->circ,
1698 holds_fulfilled => $self->fulfilled_holds,
1699 deposit_billing => $self->deposit_billing,
1700 rental_billing => $self->rental_billing,
1701 parent_circ => $pcirc,
1702 patron => ($self->return_patron) ? $self->patron : undef,
1703 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1709 sub apply_deposit_fee {
1711 my $copy = $self->copy;
1713 ($self->is_deposit and not $self->is_deposit_exempt) or
1714 ($self->is_rental and not $self->is_rental_exempt);
1716 return if $self->is_deposit and $self->skip_deposit_fee;
1717 return if $self->is_rental and $self->skip_rental_fee;
1719 my $bill = Fieldmapper::money::billing->new;
1720 my $amount = $copy->deposit_amount;
1724 if($self->is_deposit) {
1725 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1727 $self->deposit_billing($bill);
1729 $billing_type = OILS_BILLING_TYPE_RENTAL;
1731 $self->rental_billing($bill);
1734 $bill->xact($self->circ->id);
1735 $bill->amount($amount);
1736 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1737 $bill->billing_type($billing_type);
1738 $bill->btype($btype);
1739 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1741 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1746 my $copy = $self->copy;
1748 my $stat = $copy->status if ref $copy->status;
1749 my $loc = $copy->location if ref $copy->location;
1750 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1752 $copy->status($stat->id) if $stat;
1753 $copy->location($loc->id) if $loc;
1754 $copy->circ_lib($circ_lib->id) if $circ_lib;
1755 $copy->editor($self->editor->requestor->id);
1756 $copy->edit_date('now');
1757 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1759 return $self->bail_on_events($self->editor->event)
1760 unless $self->editor->update_asset_copy($self->copy);
1762 $copy->status($U->copy_status($copy->status));
1763 $copy->location($loc) if $loc;
1764 $copy->circ_lib($circ_lib) if $circ_lib;
1767 sub update_reservation {
1769 my $reservation = $self->reservation;
1771 my $usr = $reservation->usr;
1772 my $target_rt = $reservation->target_resource_type;
1773 my $target_r = $reservation->target_resource;
1774 my $current_r = $reservation->current_resource;
1776 $reservation->usr($usr->id) if ref $usr;
1777 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1778 $reservation->target_resource($target_r->id) if ref $target_r;
1779 $reservation->current_resource($current_r->id) if ref $current_r;
1781 return $self->bail_on_events($self->editor->event)
1782 unless $self->editor->update_booking_reservation($self->reservation);
1785 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1786 $self->reservation($reservation);
1790 sub bail_on_events {
1791 my( $self, @evts ) = @_;
1792 $self->push_events(@evts);
1796 # ------------------------------------------------------------------------------
1797 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1798 # affects copies that will fulfill holds and CIRC affects all other copies.
1799 # If blocks exists, bail, push Events onto the event pile, and return true.
1800 # ------------------------------------------------------------------------------
1801 sub check_hold_fulfill_blocks {
1804 # With the addition of ignore_proximity in csp, we need to fetch
1805 # the proximity of both the circ_lib and the copy's circ_lib to
1806 # the patron's home_ou.
1807 my ($ou_prox, $copy_prox);
1808 my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1809 $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1810 $ou_prox = -1 unless (defined($ou_prox));
1811 my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1812 if ($copy_ou == $self->circ_lib) {
1813 # Save us the time of an extra query.
1814 $copy_prox = $ou_prox;
1816 $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1817 $copy_prox = -1 unless (defined($copy_prox));
1820 # See if the user has any penalties applied that prevent hold fulfillment
1821 my $pens = $self->editor->json_query({
1822 select => {csp => ['name', 'label']},
1823 from => {ausp => {csp => {}}},
1826 usr => $self->patron->id,
1827 org_unit => $U->get_org_full_path($self->circ_lib),
1829 {stop_date => undef},
1830 {stop_date => {'>' => 'now'}}
1834 block_list => {'like' => '%FULFILL%'},
1836 {ignore_proximity => undef},
1837 {ignore_proximity => {'<' => $ou_prox}},
1838 {ignore_proximity => {'<' => $copy_prox}}
1844 return 0 unless @$pens;
1846 for my $pen (@$pens) {
1847 $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1848 my $event = OpenILS::Event->new($pen->{name});
1849 $event->{desc} = $pen->{label};
1850 $self->push_events($event);
1853 $self->override_events;
1854 return $self->bail_out;
1858 # ------------------------------------------------------------------------------
1859 # When an item is checked out, see if we can fulfill a hold for this patron
1860 # ------------------------------------------------------------------------------
1861 sub handle_checkout_holds {
1863 my $copy = $self->copy;
1864 my $patron = $self->patron;
1866 my $e = $self->editor;
1867 $self->fulfilled_holds([]);
1869 # non-cats can't fulfill a hold
1870 return if $self->is_noncat;
1872 my $hold = $e->search_action_hold_request({
1873 current_copy => $copy->id ,
1874 cancel_time => undef,
1875 fulfillment_time => undef
1878 if($hold and $hold->usr != $patron->id) {
1879 # reset the hold since the copy is now checked out
1881 $logger->info("circulator: un-targeting hold ".$hold->id.
1882 " because copy ".$copy->id." is getting checked out");
1884 $hold->clear_prev_check_time;
1885 $hold->clear_current_copy;
1886 $hold->clear_capture_time;
1887 $hold->clear_shelf_time;
1888 $hold->clear_shelf_expire_time;
1889 $hold->clear_current_shelf_lib;
1891 return $self->bail_on_event($e->event)
1892 unless $e->update_action_hold_request($hold);
1898 $hold = $self->find_related_user_hold($copy, $patron) or return;
1899 $logger->info("circulator: found related hold to fulfill in checkout");
1902 return if $self->check_hold_fulfill_blocks;
1904 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1906 # if the hold was never officially captured, capture it.
1907 $hold->current_copy($copy->id);
1908 $hold->capture_time('now') unless $hold->capture_time;
1909 $hold->fulfillment_time('now');
1910 $hold->fulfillment_staff($e->requestor->id);
1911 $hold->fulfillment_lib($self->circ_lib);
1913 return $self->bail_on_events($e->event)
1914 unless $e->update_action_hold_request($hold);
1916 return $self->fulfilled_holds([$hold->id]);
1920 # ------------------------------------------------------------------------------
1921 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1922 # the patron directly targets the checked out item, see if there is another hold
1923 # for the patron that could be fulfilled by the checked out item. Fulfill the
1924 # oldest hold and only fulfill 1 of them.
1926 # For "another hold":
1928 # First, check for one that the copy matches via hold_copy_map, ensuring that
1929 # *any* hold type that this copy could fill may end up filled.
1931 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1932 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1933 # that are non-requestable to count as capturing those hold types.
1934 # ------------------------------------------------------------------------------
1935 sub find_related_user_hold {
1936 my($self, $copy, $patron) = @_;
1937 my $e = $self->editor;
1939 # holds on precat copies are always copy-level, so this call will
1940 # always return undef. Exit early.
1941 return undef if $self->is_precat;
1943 return undef unless $U->ou_ancestor_setting_value(
1944 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1946 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1948 select => {ahr => ['id']},
1957 fkey => 'current_copy',
1958 type => 'left' # there may be no current_copy
1965 fulfillment_time => undef,
1966 cancel_time => undef,
1968 {expire_time => undef},
1969 {expire_time => {'>' => 'now'}}
1973 target_copy => $self->copy->id
1977 {id => undef}, # left-join copy may be nonexistent
1978 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1982 order_by => {ahr => {request_time => {direction => 'asc'}}},
1986 my $hold_info = $e->json_query($args)->[0];
1987 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1988 return undef if $U->ou_ancestor_setting_value(
1989 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1991 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1993 select => {ahr => ['id']},
1998 fkey => 'current_copy',
1999 type => 'left' # there may be no current_copy
2006 fulfillment_time => undef,
2007 cancel_time => undef,
2009 {expire_time => undef},
2010 {expire_time => {'>' => 'now'}}
2017 target => $self->volume->id
2023 target => $self->title->id
2029 {id => undef}, # left-join copy may be nonexistent
2030 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2034 order_by => {ahr => {request_time => {direction => 'asc'}}},
2038 $hold_info = $e->json_query($args)->[0];
2039 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2044 sub run_checkout_scripts {
2057 my $hard_due_date_name;
2059 $self->run_indb_circ_test();
2060 $duration = $self->circ_matrix_matchpoint->duration_rule;
2061 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2062 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2063 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2065 $duration_name = $duration->name if $duration;
2066 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2069 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2070 return $self->bail_on_events($evt) if ($evt && !$nobail);
2072 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2073 return $self->bail_on_events($evt) if ($evt && !$nobail);
2075 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2076 return $self->bail_on_events($evt) if ($evt && !$nobail);
2078 if($hard_due_date_name) {
2079 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2080 return $self->bail_on_events($evt) if ($evt && !$nobail);
2086 # The item circulates with an unlimited duration
2090 $hard_due_date = undef;
2093 $self->duration_rule($duration);
2094 $self->recurring_fines_rule($recurring);
2095 $self->max_fine_rule($max_fine);
2096 $self->hard_due_date($hard_due_date);
2100 sub build_checkout_circ_object {
2103 my $circ = Fieldmapper::action::circulation->new;
2104 my $duration = $self->duration_rule;
2105 my $max = $self->max_fine_rule;
2106 my $recurring = $self->recurring_fines_rule;
2107 my $hard_due_date = $self->hard_due_date;
2108 my $copy = $self->copy;
2109 my $patron = $self->patron;
2110 my $duration_date_ceiling;
2111 my $duration_date_ceiling_force;
2115 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2116 $duration_date_ceiling = $policy->{duration_date_ceiling};
2117 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2119 my $dname = $duration->name;
2120 my $mname = $max->name;
2121 my $rname = $recurring->name;
2123 if($hard_due_date) {
2124 $hdname = $hard_due_date->name;
2127 $logger->debug("circulator: building circulation ".
2128 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2130 $circ->duration($policy->{duration});
2131 $circ->recurring_fine($policy->{recurring_fine});
2132 $circ->duration_rule($duration->name);
2133 $circ->recurring_fine_rule($recurring->name);
2134 $circ->max_fine_rule($max->name);
2135 $circ->max_fine($policy->{max_fine});
2136 $circ->fine_interval($recurring->recurrence_interval);
2137 $circ->renewal_remaining($duration->max_renewals);
2138 $circ->auto_renewal_remaining($duration->max_auto_renewals);
2139 $circ->grace_period($policy->{grace_period});
2143 $logger->info("circulator: copy found with an unlimited circ duration");
2144 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2145 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2146 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2147 $circ->renewal_remaining(0);
2148 $circ->grace_period(0);
2151 $circ->target_copy( $copy->id );
2152 $circ->usr( $patron->id );
2153 $circ->circ_lib( $self->circ_lib );
2154 $circ->workstation($self->editor->requestor->wsid)
2155 if defined $self->editor->requestor->wsid;
2157 # renewals maintain a link to the parent circulation
2158 $circ->parent_circ($self->parent_circ);
2160 if( $self->is_renewal ) {
2161 $circ->opac_renewal('t') if $self->opac_renewal;
2162 $circ->phone_renewal('t') if $self->phone_renewal;
2163 $circ->desk_renewal('t') if $self->desk_renewal;
2164 $circ->auto_renewal('t') if $self->auto_renewal;
2165 $circ->renewal_remaining($self->renewal_remaining);
2166 $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2167 $circ->circ_staff($self->editor->requestor->id);
2170 # if the user provided an overiding checkout time,
2171 # (e.g. the checkout really happened several hours ago), then
2172 # we apply that here. Does this need a perm??
2173 $circ->xact_start(clean_ISO8601($self->checkout_time))
2174 if $self->checkout_time;
2176 # if a patron is renewing, 'requestor' will be the patron
2177 $circ->circ_staff($self->editor->requestor->id);
2178 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2183 sub do_reservation_pickup {
2186 $self->log_me("do_reservation_pickup()");
2188 $self->reservation->pickup_time('now');
2191 $self->reservation->current_resource &&
2192 $U->is_true($self->reservation->target_resource_type->catalog_item)
2194 # We used to try to set $self->copy and $self->patron here,
2195 # but that should already be done.
2197 $self->run_checkout_scripts(1);
2199 my $duration = $self->duration_rule;
2200 my $max = $self->max_fine_rule;
2201 my $recurring = $self->recurring_fines_rule;
2203 if ($duration && $max && $recurring) {
2204 my $policy = $self->get_circ_policy($duration, $recurring, $max);
2206 my $dname = $duration->name;
2207 my $mname = $max->name;
2208 my $rname = $recurring->name;
2210 $logger->debug("circulator: updating reservation ".
2211 "with duration=$dname, maxfine=$mname, recurring=$rname");
2213 $self->reservation->fine_amount($policy->{recurring_fine});
2214 $self->reservation->max_fine($policy->{max_fine});
2215 $self->reservation->fine_interval($recurring->recurrence_interval);
2218 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2219 $self->update_copy();
2222 $self->reservation->fine_amount(
2223 $self->reservation->target_resource_type->fine_amount
2225 $self->reservation->max_fine(
2226 $self->reservation->target_resource_type->max_fine
2228 $self->reservation->fine_interval(
2229 $self->reservation->target_resource_type->fine_interval
2233 $self->update_reservation();
2236 sub do_reservation_return {
2238 my $request = shift;
2240 $self->log_me("do_reservation_return()");
2242 if (not ref $self->reservation) {
2243 my ($reservation, $evt) =
2244 $U->fetch_booking_reservation($self->reservation);
2245 return $self->bail_on_events($evt) if $evt;
2246 $self->reservation($reservation);
2249 $self->handle_fines(1);
2250 $self->reservation->return_time('now');
2251 $self->update_reservation();
2252 $self->reshelve_copy if $self->copy;
2254 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2255 $self->copy( $self->reservation->current_resource->catalog_item );
2259 sub booking_adjusted_due_date {
2261 my $circ = $self->circ;
2262 my $copy = $self->copy;
2264 return undef unless $self->use_booking;
2268 if( $self->due_date ) {
2270 return $self->bail_on_events($self->editor->event)
2271 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2273 $circ->due_date(clean_ISO8601($self->due_date));
2277 return unless $copy and $circ->due_date;
2280 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2281 if (@$booking_items) {
2282 my $booking_item = $booking_items->[0];
2283 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2285 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2286 my $shorten_circ_setting = $resource_type->elbow_room ||
2287 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2290 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2291 my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2292 resource => $booking_item->id
2293 , search_start => 'now'
2294 , search_end => $circ->due_date
2295 , fields => { cancel_time => undef, return_time => undef }
2297 $booking_ses->disconnect;
2299 throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2300 return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2302 my $dt_parser = DateTime::Format::ISO8601->new;
2303 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($circ->due_date) );
2305 for my $bid (@$bookings) {
2307 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2309 my $booking_start = $dt_parser->parse_datetime( clean_ISO8601($booking->start_time) );
2310 my $booking_end = $dt_parser->parse_datetime( clean_ISO8601($booking->end_time) );
2312 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2313 if ($booking_start < DateTime->now);
2316 if ($U->is_true($stop_circ_setting)) {
2317 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2319 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2320 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2323 # We set the circ duration here only to affect the logic that will
2324 # later (in a DB trigger) mangle the time part of the due date to
2325 # 11:59pm. Having any circ duration that is not a whole number of
2326 # days is enough to prevent the "correction."
2327 my $new_circ_duration = $due_date->epoch - time;
2328 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2329 $circ->duration("$new_circ_duration seconds");
2331 $circ->due_date(clean_ISO8601($due_date->strftime('%FT%T%z')));
2335 return $self->bail_on_events($self->editor->event)
2336 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2342 sub apply_modified_due_date {
2344 my $shift_earlier = shift;
2345 my $circ = $self->circ;
2346 my $copy = $self->copy;
2348 if( $self->due_date ) {
2350 return $self->bail_on_events($self->editor->event)
2351 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2353 $circ->due_date(clean_ISO8601($self->due_date));
2357 # if the due_date lands on a day when the location is closed
2358 return unless $copy and $circ->due_date;
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});
2390 sub create_due_date {
2391 my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2393 # Look up circulating library's TZ, or else use client TZ, falling
2395 my $tz = $U->ou_ancestor_setting_value(
2401 my $due_date = $start_time ?
2402 DateTime::Format::ISO8601
2404 ->parse_datetime(clean_ISO8601($start_time))
2405 ->set_time_zone($tz) :
2406 DateTime->now(time_zone => $tz);
2408 # add the circ duration
2409 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($duration, $due_date));
2412 my $cdate = DateTime::Format::ISO8601
2414 ->parse_datetime(clean_ISO8601($date_ceiling))
2415 ->set_time_zone($tz);
2417 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2418 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2423 # return ISO8601 time with timezone
2424 return $due_date->strftime('%FT%T%z');
2429 sub make_precat_copy {
2431 my $copy = $self->copy;
2432 return $self->bail_on_events(OpenILS::Event->new('PERM_FAILURE'))
2433 unless $self->editor->allowed('CREATE_PRECAT') || $self->is_renewal;
2436 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2438 $copy->editor($self->editor->requestor->id);
2439 $copy->edit_date('now');
2440 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2441 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2442 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2443 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2444 $self->update_copy();
2448 $logger->info("circulator: Creating a new precataloged ".
2449 "copy in checkout with barcode " . $self->copy_barcode);
2451 $copy = Fieldmapper::asset::copy->new;
2452 $copy->circ_lib($self->circ_lib);
2453 $copy->creator($self->editor->requestor->id);
2454 $copy->editor($self->editor->requestor->id);
2455 $copy->barcode($self->copy_barcode);
2456 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2457 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2458 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2460 $copy->dummy_title($self->dummy_title || "");
2461 $copy->dummy_author($self->dummy_author || "");
2462 $copy->dummy_isbn($self->dummy_isbn || "");
2463 $copy->circ_modifier($self->circ_modifier);
2466 # See if we need to override the circ_lib for the copy with a configured circ_lib
2467 # Setting is shortname of the org unit
2468 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2469 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2471 if($precat_circ_lib) {
2472 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2475 $self->bail_on_events($self->editor->event);
2479 $copy->circ_lib($org->id);
2483 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2485 $self->push_events($self->editor->event);
2491 sub checkout_noncat {
2497 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2498 my $count = $self->noncat_count || 1;
2499 my $cotime = clean_ISO8601($self->checkout_time) || "";
2501 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2505 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2506 $self->editor->requestor->id,
2514 $self->push_events($evt);
2522 # if an item is in transit but the status doesn't agree, then we need to fix things.
2523 # The next two subs will hopefully do that
2524 sub fix_broken_transit_status {
2527 # Capture the transit so we don't have to fetch it again later during checkin
2528 # This used to live in sub check_transit_checkin_interval and later again in
2531 $self->editor->search_action_transit_copy(
2532 {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2536 if ($self->transit && $U->copy_status($self->copy->status)->id != OILS_COPY_STATUS_IN_TRANSIT) {
2537 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2538 " that is in-transit but without the In Transit status... fixing");
2539 $self->copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2540 # FIXME - do we want to make this permanent if the checkin bails?
2545 sub cancel_transit_if_circ_exists {
2547 if ($self->circ && $self->transit) {
2548 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2549 " that is in-transit AND circulating... aborting the transit");
2550 my $circ_ses = create OpenSRF::AppSession("open-ils.circ");
2551 my $result = $circ_ses->request(
2552 "open-ils.circ.transit.abort",
2553 $self->editor->authtoken,
2554 { 'transitid' => $self->transit->id }
2556 $logger->warn("circulator: transit abort result: ".$result);
2557 $circ_ses->disconnect;
2558 $self->transit(undef);
2562 # If a copy goes into transit and is then checked in before the transit checkin
2563 # interval has expired, push an event onto the overridable events list.
2564 sub check_transit_checkin_interval {
2567 # only concerned with in-transit items
2568 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2570 # no interval, no problem
2571 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2572 return unless $interval;
2574 # transit from X to X for whatever reason has no min interval
2575 return if $self->transit->source == $self->transit->dest;
2577 my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2578 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2579 my $horizon = $t_start->add(seconds => $seconds);
2581 # See if we are still within the transit checkin forbidden range
2582 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2583 if $horizon > DateTime->now;
2586 # Retarget local holds at checkin
2587 sub checkin_retarget {
2589 return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2590 return unless $self->is_checkin; # Renewals need not be checked
2591 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2592 return if $self->is_precat; # No holds for precats
2593 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2594 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2595 my $status = $U->copy_status($self->copy->status);
2596 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2597 # Specifically target items that are likely new (by status ID)
2598 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2599 my $location = $self->copy->location;
2600 if(!ref($location)) {
2601 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2602 $self->copy->location($location);
2604 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2606 # Fetch holds for the bib
2607 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2608 $self->editor->authtoken,
2611 capture_time => undef, # No touching captured holds
2612 frozen => 'f', # Don't bother with frozen holds
2613 pickup_lib => $self->circ_lib # Only holds actually here
2616 # Error? Skip the step.
2617 return if exists $result->{"ilsevent"};
2621 foreach my $holdlist (keys %{$result}) {
2622 push @$holds, @{$result->{$holdlist}};
2625 return if scalar(@$holds) == 0; # No holds, no retargeting
2627 # Check for parts on this copy
2628 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2629 my %parts_hash = ();
2630 %parts_hash = map {$_->part, 1} @$parts if @$parts;
2632 # Loop over holds in request-ish order
2633 # Stage 1: Get them into request-ish order
2634 # Also grab type and target for skipping low hanging ones
2635 $result = $self->editor->json_query({
2636 "select" => { "ahr" => ["id", "hold_type", "target"] },
2637 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2638 "where" => { "id" => $holds },
2640 { "class" => "pgt", "field" => "hold_priority"},
2641 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2642 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2643 { "class" => "ahr", "field" => "request_time"}
2648 if (ref $result eq "ARRAY" and scalar @$result) {
2649 foreach (@{$result}) {
2650 # Copy level, but not this copy?
2651 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2652 and $_->{target} != $self->copy->id);
2653 # Volume level, but not this volume?
2654 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2655 if(@$parts) { # We have parts?
2657 next if ($_->{hold_type} eq 'T');
2658 # Skip part holds for parts not on this copy
2659 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2661 # No parts, no part holds
2662 next if ($_->{hold_type} eq 'P');
2664 # So much for easy stuff, attempt a retarget!
2665 my $tresult = $U->simplereq(
2666 'open-ils.hold-targeter',
2667 'open-ils.hold-targeter.target',
2668 {hold => $_->{id}, find_copy => $self->copy->id}
2670 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2671 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2679 $self->log_me("do_checkin()");
2681 return $self->bail_on_events(
2682 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2685 $self->fix_broken_transit_status; # if applicable
2686 $self->check_transit_checkin_interval;
2687 $self->checkin_retarget;
2689 # the renew code and mk_env should have already found our circulation object
2690 unless( $self->circ ) {
2692 my $circs = $self->editor->search_action_circulation(
2693 { target_copy => $self->copy->id, checkin_time => undef });
2695 $self->circ($$circs[0]);
2697 # for now, just warn if there are multiple open circs on a copy
2698 $logger->warn("circulator: we have ".scalar(@$circs).
2699 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2701 $self->cancel_transit_if_circ_exists; # if applicable
2703 my $stat = $U->copy_status($self->copy->status)->id;
2705 # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2706 # differently if they are already paid for. We need to check for this
2707 # early since overdue generation is potentially affected.
2708 my $dont_change_lost_zero = 0;
2709 if ($stat == OILS_COPY_STATUS_LOST
2710 || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2711 || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2713 # LOST fine settings are controlled by the copy's circ lib, not the the
2715 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2716 $self->copy->circ_lib->id : $self->copy->circ_lib;
2717 $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2718 $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2719 $self->editor) || 0;
2721 # Don't assume there's always a circ based on copy status
2722 if ($dont_change_lost_zero && $self->circ) {
2723 my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2724 $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2727 $self->dont_change_lost_zero($dont_change_lost_zero);
2730 my $latest_inventory = Fieldmapper::asset::latest_inventory->new;
2732 if ($self->do_inventory_update) {
2733 $latest_inventory->inventory_date('now');
2734 $latest_inventory->inventory_workstation($self->editor->requestor->wsid);
2735 $latest_inventory->copy($self->copy->id());
2737 my $alci = $self->editor->search_asset_latest_inventory(
2738 {copy => $self->copy->id}
2740 $latest_inventory = $alci->[0]
2742 $self->latest_inventory($latest_inventory);
2744 if( $self->checkin_check_holds_shelf() ) {
2745 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2746 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2747 if($self->fake_hold_dest) {
2748 $self->hold->pickup_lib($self->circ_lib);
2750 $self->checkin_flesh_events;
2754 unless( $self->is_renewal ) {
2755 return $self->bail_on_events($self->editor->event)
2756 unless $self->editor->allowed('COPY_CHECKIN');
2759 $self->push_events($self->check_copy_alert());
2760 $self->push_events($self->check_checkin_copy_status());
2762 # if the circ is marked as 'claims returned', add the event to the list
2763 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2764 if ($self->circ and $self->circ->stop_fines
2765 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2767 $self->check_circ_deposit();
2769 # handle the overridable events
2770 $self->override_events unless $self->is_renewal;
2771 return if $self->bail_out;
2774 $self->checkin_handle_circ_start;
2775 return if $self->bail_out;
2777 if (!$dont_change_lost_zero) {
2778 # if this circ is LOST and we are configured to generate overdue
2779 # fines for lost items on checkin (to fill the gap between mark
2780 # lost time and when the fines would have naturally stopped), then
2781 # stop_fines is no longer valid and should be cleared.
2783 # stop_fines will be set again during the handle_fines() stage.
2784 # XXX should this setting come from the copy circ lib (like other
2785 # LOST settings), instead of the circulation circ lib?
2786 if ($stat == OILS_COPY_STATUS_LOST) {
2787 $self->circ->clear_stop_fines if
2788 $U->ou_ancestor_setting_value(
2790 OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2795 # Set stop_fines when claimed never checked out
2796 $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2798 # handle fines for this circ, including overdue gen if needed
2799 $self->handle_fines;
2802 $self->checkin_handle_circ_finish;
2803 return if $self->bail_out;
2804 $self->checkin_changed(1);
2806 } elsif( $self->transit ) {
2807 my $hold_transit = $self->process_received_transit;
2808 $self->checkin_changed(1);
2810 if( $self->bail_out ) {
2811 $self->checkin_flesh_events;
2815 if( my $e = $self->check_checkin_copy_status() ) {
2816 # If the original copy status is special, alert the caller
2817 my $ev = $self->events;
2818 $self->events([$e]);
2819 $self->override_events;
2820 return if $self->bail_out;
2824 if( $hold_transit or
2825 $U->copy_status($self->copy->status)->id
2826 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2829 if( $hold_transit ) {
2830 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2832 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2837 if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2839 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2840 $self->reshelve_copy(1);
2841 $self->cancelled_hold_transit(1);
2842 $self->notify_hold(0); # don't notify for cancelled holds
2843 $self->fake_hold_dest(0);
2844 return if $self->bail_out;
2846 } elsif ($hold and $hold->hold_type eq 'R') {
2848 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2849 $self->notify_hold(0); # No need to notify
2850 $self->fake_hold_dest(0);
2851 $self->noop(1); # Don't try and capture for other holds/transits now
2852 $self->update_copy();
2853 $hold->fulfillment_time('now');
2854 $self->bail_on_events($self->editor->event)
2855 unless $self->editor->update_action_hold_request($hold);
2859 # hold transited to correct location
2860 if($self->fake_hold_dest) {
2861 $hold->pickup_lib($self->circ_lib);
2863 $self->checkin_flesh_events;
2868 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2870 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2871 " that is in-transit, but there is no transit.. repairing");
2872 $self->reshelve_copy(1);
2873 return if $self->bail_out;
2876 if( $self->is_renewal ) {
2877 $self->finish_fines_and_voiding;
2878 return if $self->bail_out;
2879 $self->push_events(OpenILS::Event->new('SUCCESS'));
2883 # ------------------------------------------------------------------------------
2884 # Circulations and transits are now closed where necessary. Now go on to see if
2885 # this copy can fulfill a hold or needs to be routed to a different location
2886 # ------------------------------------------------------------------------------
2888 my $needed_for_something = 0; # formerly "needed_for_hold"
2890 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2892 if (!$self->remote_hold) {
2893 if ($self->use_booking) {
2894 my $potential_hold = $self->hold_capture_is_possible;
2895 my $potential_reservation = $self->reservation_capture_is_possible;
2897 if ($potential_hold and $potential_reservation) {
2898 $logger->info("circulator: item could fulfill either hold or reservation");
2899 $self->push_events(new OpenILS::Event(
2900 "HOLD_RESERVATION_CONFLICT",
2901 "hold" => $potential_hold,
2902 "reservation" => $potential_reservation
2904 return if $self->bail_out;
2905 } elsif ($potential_hold) {
2906 $needed_for_something =
2907 $self->attempt_checkin_hold_capture;
2908 } elsif ($potential_reservation) {
2909 $needed_for_something =
2910 $self->attempt_checkin_reservation_capture;
2913 $needed_for_something = $self->attempt_checkin_hold_capture;
2916 return if $self->bail_out;
2918 unless($needed_for_something) {
2919 my $circ_lib = (ref $self->copy->circ_lib) ?
2920 $self->copy->circ_lib->id : $self->copy->circ_lib;
2922 if( $self->remote_hold ) {
2923 $circ_lib = $self->remote_hold->pickup_lib;
2924 $logger->warn("circulator: Copy ".$self->copy->barcode.
2925 " is on a remote hold's shelf, sending to $circ_lib");
2928 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2930 my $suppress_transit = 0;
2932 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2933 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2934 if($suppress_transit_source && $suppress_transit_source->{value}) {
2935 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2936 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2937 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2938 $suppress_transit = 1;
2943 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2944 # copy is where it needs to be, either for hold or reshelving
2946 $self->checkin_handle_precat();
2947 return if $self->bail_out;
2950 # copy needs to transit "home", or stick here if it's a floating copy
2952 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2953 my $res = $self->editor->json_query(
2955 'evergreen.can_float',
2956 $self->copy->floating->id,
2957 $self->copy->circ_lib,
2962 $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res;
2964 if ($can_float) { # Yep, floating, stick here
2965 $self->checkin_changed(1);
2966 $self->copy->circ_lib( $self->circ_lib );
2969 my $bc = $self->copy->barcode;
2970 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2971 $self->checkin_build_copy_transit($circ_lib);
2972 return if $self->bail_out;
2973 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2977 } else { # no-op checkin
2978 if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2979 my $res = $self->editor->json_query(
2982 'evergreen.can_float',
2983 $self->copy->floating->id,
2984 $self->copy->circ_lib,
2989 if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2990 $self->checkin_changed(1);
2991 $self->copy->circ_lib( $self->circ_lib );
2997 if($self->claims_never_checked_out and
2998 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
3000 # the item was not supposed to be checked out to the user and should now be marked as missing
3001 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
3002 $self->copy->status($next_status);
3006 $self->reshelve_copy unless $needed_for_something;
3009 return if $self->bail_out;
3011 unless($self->checkin_changed) {
3013 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
3014 my $stat = $U->copy_status($self->copy->status)->id;
3016 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
3017 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
3018 $self->bail_out(1); # no need to commit anything
3022 $self->push_events(OpenILS::Event->new('SUCCESS'))
3023 unless @{$self->events};
3026 $self->finish_fines_and_voiding;
3028 OpenILS::Utils::Penalty->calculate_penalties(
3029 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3031 $self->checkin_flesh_events;
3035 sub finish_fines_and_voiding {
3037 return unless $self->circ;
3039 return unless $self->backdate or $self->void_overdues;
3041 # void overdues after fine generation to prevent concurrent DB access to overdue billings
3042 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3044 my $evt = $CC->void_or_zero_overdues(
3045 $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3047 return $self->bail_on_events($evt) if $evt;
3049 # Make sure the circ is open or closed as necessary.
3050 $evt = $U->check_open_xact($self->editor, $self->circ->id);
3051 return $self->bail_on_events($evt) if $evt;
3057 # if a deposit was payed for this item, push the event
3058 sub check_circ_deposit {
3060 return unless $self->circ;
3061 my $deposit = $self->editor->search_money_billing(
3063 xact => $self->circ->id,
3065 }, {idlist => 1})->[0];
3067 $self->push_events(OpenILS::Event->new(
3068 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
3073 my $force = $self->force || shift;
3074 my $copy = $self->copy;
3076 my $stat = $U->copy_status($copy->status)->id;
3078 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3081 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3082 $stat != OILS_COPY_STATUS_CATALOGING and
3083 $stat != OILS_COPY_STATUS_IN_TRANSIT and
3084 $stat != $next_status )) {
3086 $copy->status( $next_status );
3088 $self->checkin_changed(1);
3093 # Returns true if the item is at the current location
3094 # because it was transited there for a hold and the
3095 # hold has not been fulfilled
3096 sub checkin_check_holds_shelf {
3098 return 0 unless $self->copy;
3101 $U->copy_status($self->copy->status)->id ==
3102 OILS_COPY_STATUS_ON_HOLDS_SHELF;
3104 # Attempt to clear shelf expired holds for this copy
3105 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3106 if($self->clear_expired);
3108 # find the hold that put us on the holds shelf
3109 my $holds = $self->editor->search_action_hold_request(
3111 current_copy => $self->copy->id,
3112 capture_time => { '!=' => undef },
3113 fulfillment_time => undef,
3114 cancel_time => undef,
3119 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3120 $self->reshelve_copy(1);
3124 my $hold = $$holds[0];
3126 $logger->info("circulator: we found a captured, un-fulfilled hold [".
3127 $hold->id. "] for copy ".$self->copy->barcode);
3129 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3130 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3131 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3132 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3133 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3134 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3135 $self->fake_hold_dest(1);
3141 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3142 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3146 $logger->info("circulator: hold is not for here..");
3147 $self->remote_hold($hold);
3152 sub checkin_handle_precat {
3154 my $copy = $self->copy;
3156 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3157 $copy->status(OILS_COPY_STATUS_CATALOGING);
3158 $self->update_copy();
3159 $self->checkin_changed(1);
3160 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3165 sub checkin_build_copy_transit {
3168 my $copy = $self->copy;
3169 my $transit = Fieldmapper::action::transit_copy->new;
3171 # if we are transiting an item to the shelf shelf, it's a hold transit
3172 if (my $hold = $self->remote_hold) {
3173 $transit = Fieldmapper::action::hold_transit_copy->new;
3174 $transit->hold($hold->id);
3176 # the item is going into transit, remove any shelf-iness
3177 if ($hold->current_shelf_lib or $hold->shelf_time) {
3178 $hold->clear_current_shelf_lib;
3179 $hold->clear_shelf_time;
3180 return $self->bail_on_events($self->editor->event)
3181 unless $self->editor->update_action_hold_request($hold);
3185 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3186 $logger->info("circulator: transiting copy to $dest");
3188 $transit->source($self->circ_lib);
3189 $transit->dest($dest);
3190 $transit->target_copy($copy->id);
3191 $transit->source_send_time('now');
3192 $transit->copy_status( $U->copy_status($copy->status)->id );
3194 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3196 if ($self->remote_hold) {
3197 return $self->bail_on_events($self->editor->event)
3198 unless $self->editor->create_action_hold_transit_copy($transit);
3200 return $self->bail_on_events($self->editor->event)
3201 unless $self->editor->create_action_transit_copy($transit);
3204 # ensure the transit is returned to the caller
3205 $self->transit($transit);
3207 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3209 $self->checkin_changed(1);
3213 sub hold_capture_is_possible {
3215 my $copy = $self->copy;
3217 # we've been explicitly told not to capture any holds
3218 return 0 if $self->capture eq 'nocapture';
3220 # See if this copy can fulfill any holds
3221 my $hold = $holdcode->find_nearest_permitted_hold(
3222 $self->editor, $copy, $self->editor->requestor, 1 # check_only
3224 return undef if ref $hold eq "HASH" and
3225 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3229 sub reservation_capture_is_possible {
3231 my $copy = $self->copy;
3233 # we've been explicitly told not to capture any holds
3234 return 0 if $self->capture eq 'nocapture';
3236 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3237 my $resv = $booking_ses->request(
3238 "open-ils.booking.reservations.could_capture",
3239 $self->editor->authtoken, $copy->barcode
3241 $booking_ses->disconnect;
3242 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3243 $self->push_events($resv);
3249 # returns true if the item was used (or may potentially be used
3250 # in subsequent calls) to capture a hold.
3251 sub attempt_checkin_hold_capture {
3253 my $copy = $self->copy;
3255 # we've been explicitly told not to capture any holds
3256 return 0 if $self->capture eq 'nocapture';
3258 # See if this copy can fulfill any holds
3259 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3260 $self->editor, $copy, $self->editor->requestor );
3263 $logger->debug("circulator: no potential permitted".
3264 "holds found for copy ".$copy->barcode);
3268 if($self->capture ne 'capture') {
3269 # see if this item is in a hold-capture-delay location
3270 my $location = $self->copy->location;
3271 if(!ref($location)) {
3272 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3273 $self->copy->location($location);
3275 if($U->is_true($location->hold_verify)) {
3276 $self->bail_on_events(
3277 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3282 $self->retarget($retarget);
3284 my $suppress_transit = 0;
3285 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3286 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3287 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3288 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3289 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3290 $suppress_transit = 1;
3291 $hold->pickup_lib($self->circ_lib);
3296 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3298 $hold->current_copy($copy->id);
3299 $hold->capture_time('now');
3300 $self->put_hold_on_shelf($hold)
3301 if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3303 # prevent DB errors caused by fetching
3304 # holds from storage, and updating through cstore
3305 $hold->clear_fulfillment_time;
3306 $hold->clear_fulfillment_staff;
3307 $hold->clear_fulfillment_lib;
3308 $hold->clear_expire_time;
3309 $hold->clear_cancel_time;
3310 $hold->clear_prev_check_time unless $hold->prev_check_time;
3312 $self->bail_on_events($self->editor->event)
3313 unless $self->editor->update_action_hold_request($hold);
3315 $self->checkin_changed(1);
3317 return 0 if $self->bail_out;
3319 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3321 if ($hold->hold_type eq 'R') {
3322 $copy->status(OILS_COPY_STATUS_CATALOGING);
3323 $hold->fulfillment_time('now');
3324 $self->noop(1); # Block other transit/hold checks
3325 $self->bail_on_events($self->editor->event)
3326 unless $self->editor->update_action_hold_request($hold);
3328 # This hold was captured in the correct location
3329 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3330 $self->push_events(OpenILS::Event->new('SUCCESS'));
3332 #$self->do_hold_notify($hold->id);
3333 $self->notify_hold($hold->id);
3338 # Hold needs to be picked up elsewhere. Build a hold
3339 # transit and route the item.
3340 $self->checkin_build_hold_transit();
3341 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3342 return 0 if $self->bail_out;
3343 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3346 # make sure we save the copy status
3348 return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3352 sub attempt_checkin_reservation_capture {
3354 my $copy = $self->copy;
3356 # we've been explicitly told not to capture any holds
3357 return 0 if $self->capture eq 'nocapture';
3359 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3360 my $evt = $booking_ses->request(
3361 "open-ils.booking.resources.capture_for_reservation",
3362 $self->editor->authtoken,
3364 1 # don't update copy - we probably have it locked
3366 $booking_ses->disconnect;
3368 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3370 "open-ils.booking.resources.capture_for_reservation " .
3371 "didn't return an event!"
3375 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3376 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3378 # not-transferable is an error event we'll pass on the user
3379 $logger->warn("reservation capture attempted against non-transferable item");
3380 $self->push_events($evt);
3382 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3383 # Re-retrieve copy as reservation capture may have changed
3384 # its status and whatnot.
3386 "circulator: booking capture win on copy " . $self->copy->id
3388 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3390 "circulator: changing copy " . $self->copy->id .
3391 "'s status from " . $self->copy->status . " to " .
3394 $self->copy->status($new_copy_status);
3397 $self->reservation($evt->{"payload"}->{"reservation"});
3399 if (exists $evt->{"payload"}->{"transit"}) {
3403 "org" => $evt->{"payload"}->{"transit"}->dest
3407 $self->checkin_changed(1);
3411 # other results are treated as "nothing to capture"
3415 sub do_hold_notify {
3416 my( $self, $holdid ) = @_;
3418 my $e = new_editor(xact => 1);
3419 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3421 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3422 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3424 $logger->info("circulator: running delayed hold notify process");
3426 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3427 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3429 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3430 hold_id => $holdid, requestor => $self->editor->requestor);
3432 $logger->debug("circulator: built hold notifier");
3434 if(!$notifier->event) {
3436 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3438 my $stat = $notifier->send_email_notify;
3439 if( $stat == '1' ) {
3440 $logger->info("circulator: hold notify succeeded for hold $holdid");
3444 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3447 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3451 sub retarget_holds {
3453 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3454 my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3455 $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3456 # no reason to wait for the return value
3460 sub checkin_build_hold_transit {
3463 my $copy = $self->copy;
3464 my $hold = $self->hold;
3465 my $trans = Fieldmapper::action::hold_transit_copy->new;
3467 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3469 $trans->hold($hold->id);
3470 $trans->source($self->circ_lib);
3471 $trans->dest($hold->pickup_lib);
3472 $trans->source_send_time("now");
3473 $trans->target_copy($copy->id);
3475 # when the copy gets to its destination, it will recover
3476 # this status - put it onto the holds shelf
3477 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3479 return $self->bail_on_events($self->editor->event)
3480 unless $self->editor->create_action_hold_transit_copy($trans);
3485 sub process_received_transit {
3487 my $copy = $self->copy;
3488 my $copyid = $self->copy->id;
3490 my $status_name = $U->copy_status($copy->status)->name;
3491 $logger->debug("circulator: attempting transit receive on ".
3492 "copy $copyid. Copy status is $status_name");
3494 my $transit = $self->transit;
3496 # Check if we are in a transit suppress range
3497 my $suppress_transit = 0;
3498 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3499 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3500 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3501 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3502 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3503 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3504 $suppress_transit = 1;
3505 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3509 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3510 # - this item is in-transit to a different location
3511 # - Or we are capturing holds as transits, so why create a new transit?
3513 my $tid = $transit->id;
3514 my $loc = $self->circ_lib;
3515 my $dest = $transit->dest;
3517 $logger->info("circulator: Fowarding transit on copy which is destined ".
3518 "for a different location. transit=$tid, copy=$copyid, current ".
3519 "location=$loc, destination location=$dest");
3521 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3523 # grab the associated hold object if available
3524 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3525 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3527 return $self->bail_on_events($evt);
3530 # The transit is received, set the receive time
3531 $transit->dest_recv_time('now');
3532 $self->bail_on_events($self->editor->event)
3533 unless $self->editor->update_action_transit_copy($transit);
3535 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3537 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3538 $copy->status( $transit->copy_status );
3539 $self->update_copy();
3540 return if $self->bail_out;
3544 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3547 # hold has arrived at destination, set shelf time
3548 $self->put_hold_on_shelf($hold);
3549 $self->bail_on_events($self->editor->event)
3550 unless $self->editor->update_action_hold_request($hold);
3551 return if $self->bail_out;
3553 $self->notify_hold($hold_transit->hold);
3556 $hold_transit = undef;
3557 $self->cancelled_hold_transit(1);
3558 $self->reshelve_copy(1);
3559 $self->fake_hold_dest(0);
3564 OpenILS::Event->new(
3567 payload => { transit => $transit, holdtransit => $hold_transit } ));
3569 return $hold_transit;
3573 # ------------------------------------------------------------------
3574 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3575 # ------------------------------------------------------------------
3576 sub put_hold_on_shelf {
3577 my($self, $hold) = @_;
3578 $hold->shelf_time('now');
3579 $hold->current_shelf_lib($self->circ_lib);
3580 $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3586 my $reservation = shift;
3587 my $dt_parser = DateTime::Format::ISO8601->new;
3589 my $obj = $reservation ? $self->reservation : $self->circ;
3591 my $lost_bill_opts = $self->lost_bill_options;
3592 my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3593 # first, restore any voided overdues for lost, if needed
3594 if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3595 my $restore_od = $U->ou_ancestor_setting_value(
3596 $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3597 $self->editor) || 0;
3598 $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3602 # next, handle normal overdue generation and apply stop_fines
3603 # XXX reservations don't have stop_fines
3604 # TODO revisit booking_reservation re: stop_fines support
3605 if ($reservation or !$obj->stop_fines) {
3608 # This is a crude check for whether we are in a grace period. The code
3609 # in generate_fines() does a more thorough job, so this exists solely
3610 # as a small optimization, and might be better off removed.
3612 # If we have a grace period
3613 if($obj->can('grace_period')) {
3614 # Parse out the due date
3615 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($obj->due_date) );
3616 # Add the grace period to the due date
3617 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($obj->grace_period));
3618 # Don't generate fines on circs still in grace period
3619 $skip_for_grace = $due_date > DateTime->now;
3621 $CC->generate_fines({circs => [$obj], editor => $self->editor})
3622 unless $skip_for_grace;
3624 if (!$reservation and !$obj->stop_fines) {
3625 $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3626 $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3627 $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3628 $obj->stop_fines_time('now');
3629 $obj->stop_fines_time($self->backdate) if $self->backdate;
3630 $self->editor->update_action_circulation($obj);
3634 # finally, handle voiding of lost item and processing fees
3635 if ($self->needs_lost_bill_handling) {
3636 my $void_cost = $U->ou_ancestor_setting_value(
3637 $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3638 $self->editor) || 0;
3639 my $void_proc_fee = $U->ou_ancestor_setting_value(
3640 $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3641 $self->editor) || 0;
3642 $self->checkin_handle_lost_or_lo_now_found(
3643 $lost_bill_opts->{void_cost_btype},
3644 $lost_bill_opts->{is_longoverdue}) if $void_cost;
3645 $self->checkin_handle_lost_or_lo_now_found(
3646 $lost_bill_opts->{void_fee_btype},
3647 $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3653 sub checkin_handle_circ_start {
3655 my $circ = $self->circ;
3656 my $copy = $self->copy;
3660 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3662 # backdate the circ if necessary
3663 if($self->backdate) {
3664 my $evt = $self->checkin_handle_backdate;
3665 return $self->bail_on_events($evt) if $evt;
3668 # Set the checkin vars since we have the item
3669 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3671 # capture the true scan time for back-dated checkins
3672 $circ->checkin_scan_time('now');
3674 $circ->checkin_staff($self->editor->requestor->id);
3675 $circ->checkin_lib($self->circ_lib);
3676 $circ->checkin_workstation($self->editor->requestor->wsid);
3678 my $circ_lib = (ref $self->copy->circ_lib) ?
3679 $self->copy->circ_lib->id : $self->copy->circ_lib;
3680 my $stat = $U->copy_status($self->copy->status)->id;
3682 if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3683 # we will now handle lost fines, but the copy will retain its 'lost'
3684 # status if it needs to transit home unless lost_immediately_available
3687 # if we decide to also delay fine handling until the item arrives home,
3688 # we will need to call lost fine handling code both when checking items
3689 # in and also when receiving transits
3690 $self->checkin_handle_lost($circ_lib);
3691 } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3692 # same process as above.
3693 $self->checkin_handle_long_overdue($circ_lib);
3694 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3695 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3697 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3698 $self->copy->status($U->copy_status($next_status));
3705 sub checkin_handle_circ_finish {
3707 my $e = $self->editor;
3708 my $circ = $self->circ;
3710 # Do one last check before the final circulation update to see
3711 # if the xact_finish value should be set or not.
3713 # The underlying money.billable_xact may have been updated to
3714 # reflect a change in xact_finish during checkin bills handling,
3715 # however we can't simply refresh the circulation from the DB,
3716 # because other changes may be pending. Instead, reproduce the
3717 # xact_finish check here. It won't hurt to do it again.
3719 my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3720 if ($sum) { # is this test still needed?
3722 my $balance = $sum->balance_owed;
3724 if ($balance == 0) {
3725 $circ->xact_finish('now');
3727 $circ->clear_xact_finish;
3730 $logger->info("circulator: $balance is owed on this circulation");
3733 return $self->bail_on_events($e->event)
3734 unless $e->update_action_circulation($circ);
3739 # ------------------------------------------------------------------
3740 # See if we need to void billings, etc. for lost checkin
3741 # ------------------------------------------------------------------
3742 sub checkin_handle_lost {
3744 my $circ_lib = shift;
3746 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3747 OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3749 $self->lost_bill_options({
3750 circ_lib => $circ_lib,
3751 ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3752 ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3753 ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3754 void_cost_btype => 3,
3758 return $self->checkin_handle_lost_or_longoverdue(
3759 circ_lib => $circ_lib,
3760 max_return => $max_return,
3761 ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3762 ous_use_last_activity => undef # not supported for LOST checkin
3766 # ------------------------------------------------------------------
3767 # See if we need to void billings, etc. for long-overdue checkin
3768 # note: not using constants below since they serve little purpose
3769 # for single-use strings that are descriptive in their own right
3770 # and mostly just complicate debugging.
3771 # ------------------------------------------------------------------
3772 sub checkin_handle_long_overdue {
3774 my $circ_lib = shift;
3776 $logger->info("circulator: processing long-overdue checkin...");
3778 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3779 'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3781 $self->lost_bill_options({
3782 circ_lib => $circ_lib,
3783 ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3784 ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3785 is_longoverdue => 1,
3786 ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3787 void_cost_btype => 10,
3788 void_fee_btype => 11
3791 return $self->checkin_handle_lost_or_longoverdue(
3792 circ_lib => $circ_lib,
3793 max_return => $max_return,
3794 ous_immediately_available => 'circ.longoverdue_immediately_available',
3795 ous_use_last_activity =>
3796 'circ.longoverdue.use_last_activity_date_on_return'
3800 # last billing activity is last payment time, last billing time, or the
3801 # circ due date. If the relevant "use last activity" org unit setting is
3802 # false/unset, then last billing activity is always the due date.
3803 sub get_circ_last_billing_activity {
3805 my $circ_lib = shift;
3806 my $setting = shift;
3807 my $date = $self->circ->due_date;
3809 return $date unless $setting and
3810 $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3812 my $xact = $self->editor->retrieve_money_billable_transaction([
3814 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3817 if ($xact->summary) {
3818 $date = $xact->summary->last_payment_ts ||
3819 $xact->summary->last_billing_ts ||
3820 $self->circ->due_date;
3827 sub checkin_handle_lost_or_longoverdue {
3828 my ($self, %args) = @_;
3830 my $circ = $self->circ;
3831 my $max_return = $args{max_return};
3832 my $circ_lib = $args{circ_lib};
3837 $self->get_circ_last_billing_activity(
3838 $circ_lib, $args{ous_use_last_activity});
3841 my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3842 $tm[5] -= 1 if $tm[5] > 0;
3843 my $due = timelocal(int($tm[1]), int($tm[2]),
3844 int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3847 OpenILS::Utils::DateTime->interval_to_seconds($max_return) + int($due);
3849 $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3850 "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3851 "DUE: $due LAST: $last_chance");
3853 $max_return = 0 if $today < $last_chance;
3859 $logger->info("circulator: check-in of lost/lo item exceeds max ".
3860 "return interval. skipping fine/fee voiding, etc.");
3862 } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3864 $logger->info("circulator: check-in of lost/lo item having a balance ".
3865 "of zero, skipping fine/fee voiding and reinstatement.");
3867 } else { # within max-return interval or no interval defined
3869 $logger->info("circulator: check-in of lost/lo item is within the ".
3870 "max return interval (or no interval is defined). Proceeding ".
3871 "with fine/fee voiding, etc.");
3873 $self->needs_lost_bill_handling(1);
3876 if ($circ_lib != $self->circ_lib) {
3877 # if the item is not home, check to see if we want to retain the
3878 # lost/longoverdue status at this point in the process
3880 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib,
3881 $args{ous_immediately_available}, $self->editor) || 0;
3883 if ($immediately_available) {
3884 # item status does not need to be retained, so give it a
3885 # reshelving status as if it were a normal checkin
3886 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3887 $self->copy->status($U->copy_status($next_status));
3890 $logger->info("circulator: leaving lost/longoverdue copy".
3891 " status in place on checkin");
3894 # lost/longoverdue item is home and processed, treat like a normal
3895 # checkin from this point on
3896 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3897 $self->copy->status($U->copy_status($next_status));
3903 sub checkin_handle_backdate {
3906 # ------------------------------------------------------------------
3907 # clean up the backdate for date comparison
3908 # XXX We are currently taking the due-time from the original due-date,
3909 # not the input. Do we need to do this? This certainly interferes with
3910 # backdating of hourly checkouts, but that is likely a very rare case.
3911 # ------------------------------------------------------------------
3912 my $bd = clean_ISO8601($self->backdate);
3913 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->circ->due_date));
3914 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3915 $new_date->set_hour($original_date->hour());
3916 $new_date->set_minute($original_date->minute());
3917 if ($new_date >= DateTime->now) {
3918 # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3919 # $self->backdate() autoload handler ignores undef values.
3920 # Clear the backdate manually.
3921 $logger->info("circulator: ignoring future backdate: $new_date");
3922 delete $self->{backdate};
3924 $self->backdate(clean_ISO8601($new_date->datetime()));
3931 sub check_checkin_copy_status {
3933 my $copy = $self->copy;
3935 my $status = $U->copy_status($copy->status)->id;
3938 if( $self->new_copy_alerts ||
3939 $status == OILS_COPY_STATUS_AVAILABLE ||
3940 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3941 $status == OILS_COPY_STATUS_IN_PROCESS ||
3942 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3943 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3944 $status == OILS_COPY_STATUS_CATALOGING ||
3945 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3946 $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
3947 $status == OILS_COPY_STATUS_RESHELVING );
3949 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3950 if( $status == OILS_COPY_STATUS_LOST );
3952 return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
3953 if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
3955 return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
3956 if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
3958 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3959 if( $status == OILS_COPY_STATUS_MISSING );
3961 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3966 # --------------------------------------------------------------------------
3967 # On checkin, we need to return as many relevant objects as we can
3968 # --------------------------------------------------------------------------
3969 sub checkin_flesh_events {
3972 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3973 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3974 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3977 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3980 if($self->hold and !$self->hold->cancel_time) {
3981 $hold = $self->hold;
3982 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3986 # update our copy of the circ object and
3987 # flesh the billing summary data
3989 $self->editor->retrieve_action_circulation([
3993 circ => ['billable_transaction'],
4002 # flesh some patron fields before returning
4004 $self->editor->retrieve_actor_user([
4009 au => ['card', 'billing_address', 'mailing_address']
4016 if ($self->latest_inventory) {
4017 # flesh some workstation fields before returning
4018 $self->latest_inventory->inventory_workstation(
4019 $self->editor->retrieve_actor_workstation([$self->latest_inventory->inventory_workstation])
4023 if($self->latest_inventory && !$self->latest_inventory->id) {
4024 my $alci = $self->editor->search_asset_latest_inventory(
4025 {copy => $self->latest_inventory->copy}
4028 $self->latest_inventory->id($alci->[0]->id);
4031 $self->copy->latest_inventory($self->latest_inventory);
4033 for my $evt (@{$self->events}) {
4036 $payload->{copy} = $U->unflesh_copy($self->copy);
4037 $payload->{volume} = $self->volume;
4038 $payload->{record} = $record,
4039 $payload->{circ} = $self->circ;
4040 $payload->{transit} = $self->transit;
4041 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
4042 $payload->{hold} = $hold;
4043 $payload->{patron} = $self->patron;
4044 $payload->{reservation} = $self->reservation
4045 unless (not $self->reservation or $self->reservation->cancel_time);
4046 $payload->{latest_inventory} = $self->latest_inventory;
4047 if ($self->do_inventory_update) { $payload->{do_inventory_update} = 1; }
4049 $evt->{payload} = $payload;
4054 my( $self, $msg ) = @_;
4055 my $bc = ($self->copy) ? $self->copy->barcode :
4058 my $usr = ($self->patron) ? $self->patron->id : "";
4059 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
4060 ", recipient=$usr, copy=$bc");
4067 $self->log_me("do_renew()");
4069 # Make sure there is an open circ to renew
4070 my $usrid = $self->patron->id if $self->patron;
4071 my $circ = $self->editor->search_action_circulation({
4072 target_copy => $self->copy->id,
4073 xact_finish => undef,
4074 checkin_time => undef,
4075 ($usrid ? (usr => $usrid) : ())
4078 return $self->bail_on_events($self->editor->event) unless $circ;
4080 # A user is not allowed to renew another user's items without permission
4081 unless( $circ->usr eq $self->editor->requestor->id ) {
4082 return $self->bail_on_events($self->editor->events)
4083 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
4086 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
4087 if $circ->renewal_remaining < 1;
4089 $self->push_events(OpenILS::Event->new('MAX_AUTO_RENEWALS_REACHED'))
4090 if $self->auto_renewal and $circ->auto_renewal_remaining < 1;
4091 # -----------------------------------------------------------------
4093 $self->parent_circ($circ->id);
4094 $self->renewal_remaining( $circ->renewal_remaining - 1 );
4095 $self->auto_renewal_remaining( $circ->auto_renewal_remaining - 1 ) if (defined($circ->auto_renewal_remaining));
4098 # Opac renewal - re-use circ library from original circ (unless told not to)
4099 if($self->opac_renewal or $self->auto_renewal) {
4100 unless(defined($opac_renewal_use_circ_lib)) {
4101 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
4102 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4103 $opac_renewal_use_circ_lib = 1;
4106 $opac_renewal_use_circ_lib = 0;
4109 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4112 # Desk renewal - re-use circ library from original circ (unless told not to)
4113 if($self->desk_renewal) {
4114 unless(defined($desk_renewal_use_circ_lib)) {
4115 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4116 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4117 $desk_renewal_use_circ_lib = 1;
4120 $desk_renewal_use_circ_lib = 0;
4123 $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
4126 # Check if expired patron is allowed to renew, and bail if not.
4127 my $expire = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->patron->expire_date));
4128 if (CORE::time > $expire->epoch) {
4129 my $allow_renewal = $U->ou_ancestor_setting_value($self->circ_lib, OILS_SETTING_ALLOW_RENEW_FOR_EXPIRED_PATRON);
4130 unless ($U->is_true($allow_renewal)) {
4131 return $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'));
4135 # Run the fine generator against the old circ
4136 # XXX This seems unnecessary, given that handle_fines runs in do_checkin
4137 # a few lines down. Commenting out, for now.
4138 #$self->handle_fines;
4140 $self->run_renew_permit;
4143 $self->do_checkin();
4144 return if $self->bail_out;
4146 unless( $self->permit_override ) {
4148 return if $self->bail_out;
4149 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
4150 $self->remove_event('ITEM_NOT_CATALOGED');
4153 $self->override_events;
4154 return if $self->bail_out;
4157 $self->do_checkout();
4162 my( $self, $evt ) = @_;
4163 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4164 $logger->debug("circulator: removing event from list: $evt");
4165 my @events = @{$self->events};
4166 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
4171 my( $self, $evt ) = @_;
4172 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4173 return grep { $_->{textcode} eq $evt } @{$self->events};
4177 sub run_renew_permit {
4180 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
4181 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
4182 $self->editor, $self->copy, $self->editor->requestor, 1
4184 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
4187 my $results = $self->run_indb_circ_test;
4188 $self->push_events($self->matrix_test_result_events)
4189 unless $self->circ_test_success;
4193 # XXX: The primary mechanism for storing circ history is now handled
4194 # by tracking real circulation objects instead of bibs in a bucket.
4195 # However, this code is disabled by default and could be useful
4196 # some day, so may as well leave it for now.
4197 sub append_reading_list {
4201 $self->is_checkout and
4207 # verify history is globally enabled and uses the bucket mechanism
4208 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
4209 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
4211 return undef unless $htype and $htype eq 'bucket';
4213 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
4215 # verify the patron wants to retain the hisory
4216 my $setting = $e->search_actor_user_setting(
4217 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
4219 unless($setting and $setting->value) {
4224 my $bkt = $e->search_container_copy_bucket(
4225 {owner => $self->patron->id, btype => 'circ_history'})->[0];
4230 # find the next item position
4231 my $last_item = $e->search_container_copy_bucket_item(
4232 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
4233 $pos = $last_item->pos + 1 if $last_item;
4236 # create the history bucket if necessary
4237 $bkt = Fieldmapper::container::copy_bucket->new;
4238 $bkt->owner($self->patron->id);
4240 $bkt->btype('circ_history');
4242 $e->create_container_copy_bucket($bkt) or return $e->die_event;
4245 my $item = Fieldmapper::container::copy_bucket_item->new;
4247 $item->bucket($bkt->id);
4248 $item->target_copy($self->copy->id);
4251 $e->create_container_copy_bucket_item($item) or return $e->die_event;
4258 sub make_trigger_events {
4260 return unless $self->circ;
4261 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
4262 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
4263 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
4268 sub checkin_handle_lost_or_lo_now_found {
4269 my ($self, $bill_type, $is_longoverdue) = @_;
4271 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4273 $logger->debug("voiding $tag item billings");
4274 my $result = $CC->void_or_zero_bills_of_type($self->editor, $self->circ, $self->copy, $bill_type, "$tag ITEM RETURNED");
4275 $self->bail_on_events($self->editor->event) if ($result);
4278 sub checkin_handle_lost_or_lo_now_found_restore_od {
4280 my $circ_lib = shift;
4281 my $is_longoverdue = shift;
4282 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4284 # ------------------------------------------------------------------
4285 # restore those overdue charges voided when item was set to lost
4286 # ------------------------------------------------------------------
4288 my $ods = $self->editor->search_money_billing([
4290 xact => $self->circ->id,
4294 order_by => {mb => 'billing_ts desc'}
4298 $logger->debug("returning ".scalar(@$ods)." overdue charges pre-$tag");
4299 # Because actual users get up to all kinds of unexpectedness, we
4300 # only recreate up to $circ->max_fine in bills. I know you think
4301 # it wouldn't happen that bills could get created, voided, and
4302 # recreated more than once, but I guaran-damn-tee you that it will
4304 if ($ods && @$ods) {
4305 my $void_amount = 0;
4306 my $void_max = $self->circ->max_fine();
4307 # search for overdues voided the new way (aka "adjusted")
4308 my @billings = map {$_->id()} @$ods;
4309 my $voids = $self->editor->search_money_account_adjustment(
4311 billing => \@billings
4315 map {$void_amount += $_->amount()} @$voids;
4317 # if no adjustments found, assume they were voided the old way (aka "voided")
4318 for my $bill (@$ods) {
4319 if( $U->is_true($bill->voided) ) {
4320 $void_amount += $bill->amount();
4326 ($void_amount < $void_max ? $void_amount : $void_max),
4328 $ods->[0]->billing_type(),
4330 "System: $tag RETURNED - OVERDUES REINSTATED",
4331 $ods->[-1]->period_start(),
4332 $ods->[0]->period_end() # date this restoration the same as the last overdue (for possible subsequent fine generation)