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 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1024 clean_ISO8601($patron->expire_date));
1026 # An expired patron can renew with the assistance of an OUS.
1027 if($self->opac_renewal or $self->auto_renewal) {
1028 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
1029 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
1030 $self->circ_lib($self->circ->circ_lib);
1033 $self->circ_lib($patron->home_ou);
1037 my $expire_setting = $U->ou_ancestor_setting_value($self->circ_lib, OILS_SETTING_ALLOW_RENEW_FOR_EXPIRED_PATRON);
1038 unless ($self->is_renewal and $expire_setting) {
1039 if(CORE::time > $expire->epoch) {
1040 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1047 # --------------------------------------------------------------------------
1048 # Does the circ permit work
1049 # --------------------------------------------------------------------------
1053 $self->log_me("do_permit()");
1055 unless( $self->editor->requestor->id == $self->patron->id ) {
1056 return $self->bail_on_events($self->editor->event)
1057 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1060 $self->check_captured_holds();
1061 $self->do_copy_checks();
1062 return if $self->bail_out;
1063 $self->run_patron_permit_scripts();
1064 $self->run_copy_permit_scripts()
1065 unless $self->is_precat or $self->is_noncat;
1066 $self->check_item_deposit_events();
1067 $self->override_events();
1068 return if $self->bail_out;
1070 if($self->is_precat and not $self->request_precat) {
1072 OpenILS::Event->new(
1073 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1074 return $self->bail_out(1) unless $self->is_renewal;
1078 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1081 sub check_item_deposit_events {
1083 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
1084 if $self->is_deposit and not $self->is_deposit_exempt;
1085 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
1086 if $self->is_rental and not $self->is_rental_exempt;
1089 # returns true if the user is not required to pay deposits
1090 sub is_deposit_exempt {
1092 my $pid = (ref $self->patron->profile) ?
1093 $self->patron->profile->id : $self->patron->profile;
1094 my $groups = $U->ou_ancestor_setting_value(
1095 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1096 for my $grp (@$groups) {
1097 return 1 if $self->is_group_descendant($grp, $pid);
1102 # returns true if the user is not required to pay rental fees
1103 sub is_rental_exempt {
1105 my $pid = (ref $self->patron->profile) ?
1106 $self->patron->profile->id : $self->patron->profile;
1107 my $groups = $U->ou_ancestor_setting_value(
1108 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1109 for my $grp (@$groups) {
1110 return 1 if $self->is_group_descendant($grp, $pid);
1115 sub is_group_descendant {
1116 my($self, $p_id, $c_id) = @_;
1117 return 0 unless defined $p_id and defined $c_id;
1118 return 1 if $c_id == $p_id;
1119 while(my $grp = $user_groups{$c_id}) {
1120 $c_id = $grp->parent;
1121 return 0 unless defined $c_id;
1122 return 1 if $c_id == $p_id;
1127 sub check_captured_holds {
1129 my $copy = $self->copy;
1130 my $patron = $self->patron;
1132 return undef unless $copy;
1134 my $s = $U->copy_status($copy->status)->id;
1135 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1136 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1138 # Item is on the holds shelf, make sure it's going to the right person
1139 my $hold = $self->editor->search_action_hold_request(
1142 current_copy => $copy->id ,
1143 capture_time => { '!=' => undef },
1144 cancel_time => undef,
1145 fulfillment_time => undef
1149 flesh_fields => { ahr => ['usr'] }
1154 if ($hold and $hold->usr->id == $patron->id) {
1155 $self->checkout_is_for_hold(1);
1159 my $holdau = $hold->usr;
1162 $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1163 $payload->{patron_id} = $holdau->id;
1165 $payload->{patron_name} = "???";
1167 $payload->{hold_id} = $hold->id;
1168 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1169 payload => $payload));
1172 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1177 sub do_copy_checks {
1179 my $copy = $self->copy;
1180 return unless $copy;
1182 my $stat = $U->copy_status($copy->status)->id;
1184 # We cannot check out a copy if it is in-transit
1185 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1186 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1189 $self->handle_claims_returned();
1190 return if $self->bail_out;
1192 # no claims returned circ was found, check if there is any open circ
1193 unless( $self->is_renewal ) {
1195 my $circs = $self->editor->search_action_circulation(
1196 { target_copy => $copy->id, checkin_time => undef }
1199 if(my $old_circ = $circs->[0]) { # an open circ was found
1201 my $payload = {copy => $copy};
1203 if($old_circ->usr == $self->patron->id) {
1205 $payload->{old_circ} = $old_circ;
1207 # If there is an open circulation on the checkout item and an auto-renew
1208 # interval is defined, inform the caller that they should go
1209 # ahead and renew the item instead of warning about open circulations.
1211 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1213 'circ.checkout_auto_renew_age',
1217 if($auto_renew_intvl) {
1218 my $intvl_seconds = OpenILS::Utils::DateTime->interval_to_seconds($auto_renew_intvl);
1219 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( clean_ISO8601($old_circ->xact_start) );
1221 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1222 $payload->{auto_renew} = 1;
1227 return $self->bail_on_events(
1228 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1234 my $LEGACY_CIRC_EVENT_MAP = {
1235 'no_item' => 'ITEM_NOT_CATALOGED',
1236 'actor.usr.barred' => 'PATRON_BARRED',
1237 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1238 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1239 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1240 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1241 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1242 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1243 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1244 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1245 'config.circ_matrix_test.total_copy_hold_ratio' =>
1246 'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1247 'config.circ_matrix_test.available_copy_hold_ratio' =>
1248 'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1252 # ---------------------------------------------------------------------
1253 # This pushes any patron-related events into the list but does not
1254 # set bail_out for any events
1255 # ---------------------------------------------------------------------
1256 sub run_patron_permit_scripts {
1258 my $patronid = $self->patron->id;
1263 my $results = $self->run_indb_circ_test;
1264 unless($self->circ_test_success) {
1265 my @trimmed_results;
1267 if ($self->is_noncat) {
1268 # no_item result is OK during noncat checkout
1269 @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1273 if ($self->checkout_is_for_hold) {
1274 # if this checkout will fulfill a hold, ignore CIRC blocks
1275 # and rely instead on the (later-checked) FULFILL block
1277 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1278 my $fblock_pens = $self->editor->search_config_standing_penalty(
1279 {name => [@pen_names], block_list => {like => '%CIRC%'}});
1281 for my $res (@$results) {
1282 my $name = $res->{fail_part} || '';
1283 next if grep {$_->name eq $name} @$fblock_pens;
1284 push(@trimmed_results, $res);
1288 # not for hold or noncat
1289 @trimmed_results = @$results;
1293 # update the final set of test results
1294 $self->matrix_test_result(\@trimmed_results);
1296 push @allevents, $self->matrix_test_result_events;
1300 $_->{payload} = $self->copy if
1301 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1304 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1306 $self->push_events(@allevents);
1309 sub matrix_test_result_codes {
1311 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1314 sub matrix_test_result_events {
1317 my $event = new OpenILS::Event(
1318 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1320 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1322 } (@{$self->matrix_test_result});
1325 sub run_indb_circ_test {
1327 return $self->matrix_test_result if $self->matrix_test_result;
1329 my $dbfunc = ($self->is_renewal) ?
1330 'action.item_user_renew_test' : 'action.item_user_circ_test';
1332 if( $self->is_precat && $self->request_precat) {
1333 $self->make_precat_copy;
1334 return if $self->bail_out;
1337 my $results = $self->editor->json_query(
1341 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1347 $self->circ_test_success($U->is_true($results->[0]->{success}));
1349 if(my $mp = $results->[0]->{matchpoint}) {
1350 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1351 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1352 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1353 if(defined($results->[0]->{renewals})) {
1354 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1356 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1357 if(defined($results->[0]->{grace_period})) {
1358 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1360 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1361 if(defined($results->[0]->{hard_due_date})) {
1362 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1364 # Grab the *last* response for limit_groups, where it is more likely to be filled
1365 $self->limit_groups($results->[-1]->{limit_groups});
1368 return $self->matrix_test_result($results);
1371 # ---------------------------------------------------------------------
1372 # given a use and copy, this will calculate the circulation policy
1373 # parameters. Only works with in-db circ.
1374 # ---------------------------------------------------------------------
1378 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1380 $self->run_indb_circ_test;
1383 circ_test_success => $self->circ_test_success,
1384 failure_events => [],
1385 failure_codes => [],
1386 matchpoint => $self->circ_matrix_matchpoint
1389 unless($self->circ_test_success) {
1390 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1391 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1394 if($self->circ_matrix_matchpoint) {
1395 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1396 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1397 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1398 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1400 my $policy = $self->get_circ_policy(
1401 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1403 $$results{$_} = $$policy{$_} for keys %$policy;
1409 # ---------------------------------------------------------------------
1410 # Loads the circ policy info for duration, recurring fine, and max
1411 # fine based on the current copy
1412 # ---------------------------------------------------------------------
1413 sub get_circ_policy {
1414 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1417 duration_rule => $duration_rule->name,
1418 recurring_fine_rule => $recurring_fine_rule->name,
1419 max_fine_rule => $max_fine_rule->name,
1420 max_fine => $self->get_max_fine_amount($max_fine_rule),
1421 fine_interval => $recurring_fine_rule->recurrence_interval,
1422 renewal_remaining => $duration_rule->max_renewals,
1423 auto_renewal_remaining => $duration_rule->max_auto_renewals,
1424 grace_period => $recurring_fine_rule->grace_period
1427 if($hard_due_date) {
1428 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1429 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1432 $policy->{duration_date_ceiling} = undef;
1433 $policy->{duration_date_ceiling_force} = undef;
1436 $policy->{duration} = $duration_rule->shrt
1437 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1438 $policy->{duration} = $duration_rule->normal
1439 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1440 $policy->{duration} = $duration_rule->extended
1441 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1443 $policy->{recurring_fine} = $recurring_fine_rule->low
1444 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1445 $policy->{recurring_fine} = $recurring_fine_rule->normal
1446 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1447 $policy->{recurring_fine} = $recurring_fine_rule->high
1448 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1453 sub get_max_fine_amount {
1455 my $max_fine_rule = shift;
1456 my $max_amount = $max_fine_rule->amount;
1458 # if is_percent is true then the max->amount is
1459 # use as a percentage of the copy price
1460 if ($U->is_true($max_fine_rule->is_percent)) {
1461 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1462 $max_amount = $price * $max_fine_rule->amount / 100;
1464 $U->ou_ancestor_setting_value(
1466 'circ.max_fine.cap_at_price',
1470 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1471 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1479 sub run_copy_permit_scripts {
1481 my $copy = $self->copy || return;
1485 my $results = $self->run_indb_circ_test;
1486 push @allevents, $self->matrix_test_result_events
1487 unless $self->circ_test_success;
1489 # See if this copy has an alert message
1490 my $ae = $self->check_copy_alert();
1491 push( @allevents, $ae ) if $ae;
1493 # uniquify the events
1494 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1495 @allevents = values %hash;
1497 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1499 $self->push_events(@allevents);
1503 sub check_copy_alert {
1506 if ($self->new_copy_alerts) {
1508 push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts
1509 if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1511 push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts
1512 if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1515 $self->bail_out(1) if (!$self->override);
1516 return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1520 return undef if $self->is_renewal;
1521 return OpenILS::Event->new(
1522 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1523 if $self->copy and $self->copy->alert_message;
1529 # --------------------------------------------------------------------------
1530 # If the call is overriding and has permissions to override every collected
1531 # event, the are cleared. Any event that the caller does not have
1532 # permission to override, will be left in the event list and bail_out will
1534 # XXX We need code in here to cancel any holds/transits on copies
1535 # that are being force-checked out
1536 # --------------------------------------------------------------------------
1537 sub override_events {
1539 my @events = @{$self->events};
1540 return unless @events;
1541 my $oargs = $self->override_args;
1543 if(!$self->override) {
1544 return $self->bail_out(1)
1545 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1550 for my $e (@events) {
1551 my $tc = $e->{textcode};
1552 next if $tc eq 'SUCCESS';
1553 if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1554 my $ov = "$tc.override";
1555 $logger->info("circulator: attempting to override event: $ov");
1557 return $self->bail_on_events($self->editor->event)
1558 unless( $self->editor->allowed($ov) );
1560 return $self->bail_out(1);
1566 # --------------------------------------------------------------------------
1567 # If there is an open claimsreturn circ on the requested copy, close the
1568 # circ if overriding, otherwise bail out
1569 # --------------------------------------------------------------------------
1570 sub handle_claims_returned {
1572 my $copy = $self->copy;
1574 my $CR = $self->editor->search_action_circulation(
1576 target_copy => $copy->id,
1577 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1578 checkin_time => undef,
1582 return unless ($CR = $CR->[0]);
1586 # - If the caller has set the override flag, we will check the item in
1587 if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1589 $CR->checkin_time('now');
1590 $CR->checkin_scan_time('now');
1591 $CR->checkin_lib($self->circ_lib);
1592 $CR->checkin_workstation($self->editor->requestor->wsid);
1593 $CR->checkin_staff($self->editor->requestor->id);
1595 $evt = $self->editor->event
1596 unless $self->editor->update_action_circulation($CR);
1599 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1602 $self->bail_on_events($evt) if $evt;
1607 # --------------------------------------------------------------------------
1608 # This performs the checkout
1609 # --------------------------------------------------------------------------
1613 $self->log_me("do_checkout()");
1615 # make sure perms are good if this isn't a renewal
1616 unless( $self->is_renewal ) {
1617 return $self->bail_on_events($self->editor->event)
1618 unless( $self->editor->allowed('COPY_CHECKOUT') );
1621 # verify the permit key
1622 unless( $self->check_permit_key ) {
1623 if( $self->permit_override ) {
1624 return $self->bail_on_events($self->editor->event)
1625 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1627 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1631 # if this is a non-cataloged circ, build the circ and finish
1632 if( $self->is_noncat ) {
1633 $self->checkout_noncat;
1635 OpenILS::Event->new('SUCCESS',
1636 payload => { noncat_circ => $self->circ }));
1640 if( $self->is_precat ) {
1641 $self->make_precat_copy;
1642 return if $self->bail_out;
1644 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1645 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1648 $self->do_copy_checks;
1649 return if $self->bail_out;
1651 $self->run_checkout_scripts();
1652 return if $self->bail_out;
1654 $self->build_checkout_circ_object();
1655 return if $self->bail_out;
1657 my $modify_to_start = $self->booking_adjusted_due_date();
1658 return if $self->bail_out;
1660 $self->apply_modified_due_date($modify_to_start);
1661 return if $self->bail_out;
1663 return $self->bail_on_events($self->editor->event)
1664 unless $self->editor->create_action_circulation($self->circ);
1666 # refresh the circ to force local time zone for now
1667 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1669 if($self->limit_groups) {
1670 $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1673 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1675 return if $self->bail_out;
1677 $self->apply_deposit_fee();
1678 return if $self->bail_out;
1680 $self->handle_checkout_holds();
1681 return if $self->bail_out;
1683 # ------------------------------------------------------------------------------
1684 # Update the patron penalty info in the DB. Run it for permit-overrides
1685 # since the penalties are not updated during the permit phase
1686 # ------------------------------------------------------------------------------
1687 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1689 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1692 if($self->is_renewal) {
1693 # flesh the billing summary for the checked-in circ
1694 $pcirc = $self->editor->retrieve_action_circulation([
1696 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1701 OpenILS::Event->new('SUCCESS',
1703 copy => $U->unflesh_copy($self->copy),
1704 volume => $self->volume,
1705 circ => $self->circ,
1707 holds_fulfilled => $self->fulfilled_holds,
1708 deposit_billing => $self->deposit_billing,
1709 rental_billing => $self->rental_billing,
1710 parent_circ => $pcirc,
1711 patron => ($self->return_patron) ? $self->patron : undef,
1712 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1718 sub apply_deposit_fee {
1720 my $copy = $self->copy;
1722 ($self->is_deposit and not $self->is_deposit_exempt) or
1723 ($self->is_rental and not $self->is_rental_exempt);
1725 return if $self->is_deposit and $self->skip_deposit_fee;
1726 return if $self->is_rental and $self->skip_rental_fee;
1728 my $bill = Fieldmapper::money::billing->new;
1729 my $amount = $copy->deposit_amount;
1733 if($self->is_deposit) {
1734 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1736 $self->deposit_billing($bill);
1738 $billing_type = OILS_BILLING_TYPE_RENTAL;
1740 $self->rental_billing($bill);
1743 $bill->xact($self->circ->id);
1744 $bill->amount($amount);
1745 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1746 $bill->billing_type($billing_type);
1747 $bill->btype($btype);
1748 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1750 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1755 my $copy = $self->copy;
1757 my $stat = $copy->status if ref $copy->status;
1758 my $loc = $copy->location if ref $copy->location;
1759 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1761 $copy->status($stat->id) if $stat;
1762 $copy->location($loc->id) if $loc;
1763 $copy->circ_lib($circ_lib->id) if $circ_lib;
1764 $copy->editor($self->editor->requestor->id);
1765 $copy->edit_date('now');
1766 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1768 return $self->bail_on_events($self->editor->event)
1769 unless $self->editor->update_asset_copy($self->copy);
1771 $copy->status($U->copy_status($copy->status));
1772 $copy->location($loc) if $loc;
1773 $copy->circ_lib($circ_lib) if $circ_lib;
1776 sub update_reservation {
1778 my $reservation = $self->reservation;
1780 my $usr = $reservation->usr;
1781 my $target_rt = $reservation->target_resource_type;
1782 my $target_r = $reservation->target_resource;
1783 my $current_r = $reservation->current_resource;
1785 $reservation->usr($usr->id) if ref $usr;
1786 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1787 $reservation->target_resource($target_r->id) if ref $target_r;
1788 $reservation->current_resource($current_r->id) if ref $current_r;
1790 return $self->bail_on_events($self->editor->event)
1791 unless $self->editor->update_booking_reservation($self->reservation);
1794 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1795 $self->reservation($reservation);
1799 sub bail_on_events {
1800 my( $self, @evts ) = @_;
1801 $self->push_events(@evts);
1805 # ------------------------------------------------------------------------------
1806 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1807 # affects copies that will fulfill holds and CIRC affects all other copies.
1808 # If blocks exists, bail, push Events onto the event pile, and return true.
1809 # ------------------------------------------------------------------------------
1810 sub check_hold_fulfill_blocks {
1813 # With the addition of ignore_proximity in csp, we need to fetch
1814 # the proximity of both the circ_lib and the copy's circ_lib to
1815 # the patron's home_ou.
1816 my ($ou_prox, $copy_prox);
1817 my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1818 $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1819 $ou_prox = -1 unless (defined($ou_prox));
1820 my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1821 if ($copy_ou == $self->circ_lib) {
1822 # Save us the time of an extra query.
1823 $copy_prox = $ou_prox;
1825 $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1826 $copy_prox = -1 unless (defined($copy_prox));
1829 # See if the user has any penalties applied that prevent hold fulfillment
1830 my $pens = $self->editor->json_query({
1831 select => {csp => ['name', 'label']},
1832 from => {ausp => {csp => {}}},
1835 usr => $self->patron->id,
1836 org_unit => $U->get_org_full_path($self->circ_lib),
1838 {stop_date => undef},
1839 {stop_date => {'>' => 'now'}}
1843 block_list => {'like' => '%FULFILL%'},
1845 {ignore_proximity => undef},
1846 {ignore_proximity => {'<' => $ou_prox}},
1847 {ignore_proximity => {'<' => $copy_prox}}
1853 return 0 unless @$pens;
1855 for my $pen (@$pens) {
1856 $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1857 my $event = OpenILS::Event->new($pen->{name});
1858 $event->{desc} = $pen->{label};
1859 $self->push_events($event);
1862 $self->override_events;
1863 return $self->bail_out;
1867 # ------------------------------------------------------------------------------
1868 # When an item is checked out, see if we can fulfill a hold for this patron
1869 # ------------------------------------------------------------------------------
1870 sub handle_checkout_holds {
1872 my $copy = $self->copy;
1873 my $patron = $self->patron;
1875 my $e = $self->editor;
1876 $self->fulfilled_holds([]);
1878 # non-cats can't fulfill a hold
1879 return if $self->is_noncat;
1881 my $hold = $e->search_action_hold_request({
1882 current_copy => $copy->id ,
1883 cancel_time => undef,
1884 fulfillment_time => undef
1887 if($hold and $hold->usr != $patron->id) {
1888 # reset the hold since the copy is now checked out
1890 $logger->info("circulator: un-targeting hold ".$hold->id.
1891 " because copy ".$copy->id." is getting checked out");
1893 $hold->clear_prev_check_time;
1894 $hold->clear_current_copy;
1895 $hold->clear_capture_time;
1896 $hold->clear_shelf_time;
1897 $hold->clear_shelf_expire_time;
1898 $hold->clear_current_shelf_lib;
1900 return $self->bail_on_event($e->event)
1901 unless $e->update_action_hold_request($hold);
1907 $hold = $self->find_related_user_hold($copy, $patron) or return;
1908 $logger->info("circulator: found related hold to fulfill in checkout");
1911 return if $self->check_hold_fulfill_blocks;
1913 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1915 # if the hold was never officially captured, capture it.
1916 $hold->current_copy($copy->id);
1917 $hold->capture_time('now') unless $hold->capture_time;
1918 $hold->fulfillment_time('now');
1919 $hold->fulfillment_staff($e->requestor->id);
1920 $hold->fulfillment_lib($self->circ_lib);
1922 return $self->bail_on_events($e->event)
1923 unless $e->update_action_hold_request($hold);
1925 return $self->fulfilled_holds([$hold->id]);
1929 # ------------------------------------------------------------------------------
1930 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1931 # the patron directly targets the checked out item, see if there is another hold
1932 # for the patron that could be fulfilled by the checked out item. Fulfill the
1933 # oldest hold and only fulfill 1 of them.
1935 # For "another hold":
1937 # First, check for one that the copy matches via hold_copy_map, ensuring that
1938 # *any* hold type that this copy could fill may end up filled.
1940 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1941 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1942 # that are non-requestable to count as capturing those hold types.
1943 # ------------------------------------------------------------------------------
1944 sub find_related_user_hold {
1945 my($self, $copy, $patron) = @_;
1946 my $e = $self->editor;
1948 # holds on precat copies are always copy-level, so this call will
1949 # always return undef. Exit early.
1950 return undef if $self->is_precat;
1952 return undef unless $U->ou_ancestor_setting_value(
1953 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1955 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1957 select => {ahr => ['id']},
1966 fkey => 'current_copy',
1967 type => 'left' # there may be no current_copy
1974 fulfillment_time => undef,
1975 cancel_time => undef,
1977 {expire_time => undef},
1978 {expire_time => {'>' => 'now'}}
1982 target_copy => $self->copy->id
1986 {id => undef}, # left-join copy may be nonexistent
1987 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1991 order_by => {ahr => {request_time => {direction => 'asc'}}},
1995 my $hold_info = $e->json_query($args)->[0];
1996 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1997 return undef if $U->ou_ancestor_setting_value(
1998 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
2000 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
2002 select => {ahr => ['id']},
2007 fkey => 'current_copy',
2008 type => 'left' # there may be no current_copy
2015 fulfillment_time => undef,
2016 cancel_time => undef,
2018 {expire_time => undef},
2019 {expire_time => {'>' => 'now'}}
2026 target => $self->volume->id
2032 target => $self->title->id
2038 {id => undef}, # left-join copy may be nonexistent
2039 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2043 order_by => {ahr => {request_time => {direction => 'asc'}}},
2047 $hold_info = $e->json_query($args)->[0];
2048 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2053 sub run_checkout_scripts {
2066 my $hard_due_date_name;
2068 $self->run_indb_circ_test();
2069 $duration = $self->circ_matrix_matchpoint->duration_rule;
2070 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2071 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2072 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2074 $duration_name = $duration->name if $duration;
2075 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2078 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2079 return $self->bail_on_events($evt) if ($evt && !$nobail);
2081 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2082 return $self->bail_on_events($evt) if ($evt && !$nobail);
2084 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2085 return $self->bail_on_events($evt) if ($evt && !$nobail);
2087 if($hard_due_date_name) {
2088 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2089 return $self->bail_on_events($evt) if ($evt && !$nobail);
2095 # The item circulates with an unlimited duration
2099 $hard_due_date = undef;
2102 $self->duration_rule($duration);
2103 $self->recurring_fines_rule($recurring);
2104 $self->max_fine_rule($max_fine);
2105 $self->hard_due_date($hard_due_date);
2109 sub build_checkout_circ_object {
2112 my $circ = Fieldmapper::action::circulation->new;
2113 my $duration = $self->duration_rule;
2114 my $max = $self->max_fine_rule;
2115 my $recurring = $self->recurring_fines_rule;
2116 my $hard_due_date = $self->hard_due_date;
2117 my $copy = $self->copy;
2118 my $patron = $self->patron;
2119 my $duration_date_ceiling;
2120 my $duration_date_ceiling_force;
2124 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2125 $duration_date_ceiling = $policy->{duration_date_ceiling};
2126 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2128 my $dname = $duration->name;
2129 my $mname = $max->name;
2130 my $rname = $recurring->name;
2132 if($hard_due_date) {
2133 $hdname = $hard_due_date->name;
2136 $logger->debug("circulator: building circulation ".
2137 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2139 $circ->duration($policy->{duration});
2140 $circ->recurring_fine($policy->{recurring_fine});
2141 $circ->duration_rule($duration->name);
2142 $circ->recurring_fine_rule($recurring->name);
2143 $circ->max_fine_rule($max->name);
2144 $circ->max_fine($policy->{max_fine});
2145 $circ->fine_interval($recurring->recurrence_interval);
2146 $circ->renewal_remaining($duration->max_renewals);
2147 $circ->auto_renewal_remaining($duration->max_auto_renewals);
2148 $circ->grace_period($policy->{grace_period});
2152 $logger->info("circulator: copy found with an unlimited circ duration");
2153 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2154 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2155 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2156 $circ->renewal_remaining(0);
2157 $circ->grace_period(0);
2160 $circ->target_copy( $copy->id );
2161 $circ->usr( $patron->id );
2162 $circ->circ_lib( $self->circ_lib );
2163 $circ->workstation($self->editor->requestor->wsid)
2164 if defined $self->editor->requestor->wsid;
2166 # renewals maintain a link to the parent circulation
2167 $circ->parent_circ($self->parent_circ);
2169 if( $self->is_renewal ) {
2170 $circ->opac_renewal('t') if $self->opac_renewal;
2171 $circ->phone_renewal('t') if $self->phone_renewal;
2172 $circ->desk_renewal('t') if $self->desk_renewal;
2173 $circ->auto_renewal('t') if $self->auto_renewal;
2174 $circ->renewal_remaining($self->renewal_remaining);
2175 $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2176 $circ->circ_staff($self->editor->requestor->id);
2179 # if the user provided an overiding checkout time,
2180 # (e.g. the checkout really happened several hours ago), then
2181 # we apply that here. Does this need a perm??
2182 $circ->xact_start(clean_ISO8601($self->checkout_time))
2183 if $self->checkout_time;
2185 # if a patron is renewing, 'requestor' will be the patron
2186 $circ->circ_staff($self->editor->requestor->id);
2187 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2192 sub do_reservation_pickup {
2195 $self->log_me("do_reservation_pickup()");
2197 $self->reservation->pickup_time('now');
2200 $self->reservation->current_resource &&
2201 $U->is_true($self->reservation->target_resource_type->catalog_item)
2203 # We used to try to set $self->copy and $self->patron here,
2204 # but that should already be done.
2206 $self->run_checkout_scripts(1);
2208 my $duration = $self->duration_rule;
2209 my $max = $self->max_fine_rule;
2210 my $recurring = $self->recurring_fines_rule;
2212 if ($duration && $max && $recurring) {
2213 my $policy = $self->get_circ_policy($duration, $recurring, $max);
2215 my $dname = $duration->name;
2216 my $mname = $max->name;
2217 my $rname = $recurring->name;
2219 $logger->debug("circulator: updating reservation ".
2220 "with duration=$dname, maxfine=$mname, recurring=$rname");
2222 $self->reservation->fine_amount($policy->{recurring_fine});
2223 $self->reservation->max_fine($policy->{max_fine});
2224 $self->reservation->fine_interval($recurring->recurrence_interval);
2227 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2228 $self->update_copy();
2231 $self->reservation->fine_amount(
2232 $self->reservation->target_resource_type->fine_amount
2234 $self->reservation->max_fine(
2235 $self->reservation->target_resource_type->max_fine
2237 $self->reservation->fine_interval(
2238 $self->reservation->target_resource_type->fine_interval
2242 $self->update_reservation();
2245 sub do_reservation_return {
2247 my $request = shift;
2249 $self->log_me("do_reservation_return()");
2251 if (not ref $self->reservation) {
2252 my ($reservation, $evt) =
2253 $U->fetch_booking_reservation($self->reservation);
2254 return $self->bail_on_events($evt) if $evt;
2255 $self->reservation($reservation);
2258 $self->handle_fines(1);
2259 $self->reservation->return_time('now');
2260 $self->update_reservation();
2261 $self->reshelve_copy if $self->copy;
2263 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2264 $self->copy( $self->reservation->current_resource->catalog_item );
2268 sub booking_adjusted_due_date {
2270 my $circ = $self->circ;
2271 my $copy = $self->copy;
2273 return undef unless $self->use_booking;
2277 if( $self->due_date ) {
2279 return $self->bail_on_events($self->editor->event)
2280 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2282 $circ->due_date(clean_ISO8601($self->due_date));
2286 return unless $copy and $circ->due_date;
2289 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2290 if (@$booking_items) {
2291 my $booking_item = $booking_items->[0];
2292 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2294 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2295 my $shorten_circ_setting = $resource_type->elbow_room ||
2296 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2299 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2300 my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2301 resource => $booking_item->id
2302 , search_start => 'now'
2303 , search_end => $circ->due_date
2304 , fields => { cancel_time => undef, return_time => undef }
2306 $booking_ses->disconnect;
2308 throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2309 return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2311 my $dt_parser = DateTime::Format::ISO8601->new;
2312 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($circ->due_date) );
2314 for my $bid (@$bookings) {
2316 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2318 my $booking_start = $dt_parser->parse_datetime( clean_ISO8601($booking->start_time) );
2319 my $booking_end = $dt_parser->parse_datetime( clean_ISO8601($booking->end_time) );
2321 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2322 if ($booking_start < DateTime->now);
2325 if ($U->is_true($stop_circ_setting)) {
2326 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2328 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2329 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2332 # We set the circ duration here only to affect the logic that will
2333 # later (in a DB trigger) mangle the time part of the due date to
2334 # 11:59pm. Having any circ duration that is not a whole number of
2335 # days is enough to prevent the "correction."
2336 my $new_circ_duration = $due_date->epoch - time;
2337 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2338 $circ->duration("$new_circ_duration seconds");
2340 $circ->due_date(clean_ISO8601($due_date->strftime('%FT%T%z')));
2344 return $self->bail_on_events($self->editor->event)
2345 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2351 sub apply_modified_due_date {
2353 my $shift_earlier = shift;
2354 my $circ = $self->circ;
2355 my $copy = $self->copy;
2357 if( $self->due_date ) {
2359 return $self->bail_on_events($self->editor->event)
2360 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2362 $circ->due_date(clean_ISO8601($self->due_date));
2366 # if the due_date lands on a day when the location is closed
2367 return unless $copy and $circ->due_date;
2369 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2371 # due-date overlap should be determined by the location the item
2372 # is checked out from, not the owning or circ lib of the item
2373 my $org = $self->circ_lib;
2375 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2376 " with an item due date of ".$circ->due_date );
2378 my $dateinfo = $U->storagereq(
2379 'open-ils.storage.actor.org_unit.closed_date.overlap',
2380 $org, $circ->due_date );
2383 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2384 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2386 # XXX make the behavior more dynamic
2387 # for now, we just push the due date to after the close date
2388 if ($shift_earlier) {
2389 $circ->due_date($dateinfo->{start});
2391 $circ->due_date($dateinfo->{end});
2399 sub create_due_date {
2400 my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2402 # Look up circulating library's TZ, or else use client TZ, falling
2404 my $tz = $U->ou_ancestor_setting_value(
2410 my $due_date = $start_time ?
2411 DateTime::Format::ISO8601
2413 ->parse_datetime(clean_ISO8601($start_time))
2414 ->set_time_zone($tz) :
2415 DateTime->now(time_zone => $tz);
2417 # add the circ duration
2418 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($duration, $due_date));
2421 my $cdate = DateTime::Format::ISO8601
2423 ->parse_datetime(clean_ISO8601($date_ceiling))
2424 ->set_time_zone($tz);
2426 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2427 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2432 # return ISO8601 time with timezone
2433 return $due_date->strftime('%FT%T%z');
2438 sub make_precat_copy {
2440 my $copy = $self->copy;
2441 return $self->bail_on_events(OpenILS::Event->new('PERM_FAILURE'))
2442 unless $self->editor->allowed('CREATE_PRECAT') || $self->is_renewal;
2445 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2447 $copy->editor($self->editor->requestor->id);
2448 $copy->edit_date('now');
2449 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2450 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2451 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2452 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2453 $self->update_copy();
2457 $logger->info("circulator: Creating a new precataloged ".
2458 "copy in checkout with barcode " . $self->copy_barcode);
2460 $copy = Fieldmapper::asset::copy->new;
2461 $copy->circ_lib($self->circ_lib);
2462 $copy->creator($self->editor->requestor->id);
2463 $copy->editor($self->editor->requestor->id);
2464 $copy->barcode($self->copy_barcode);
2465 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2466 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2467 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2469 $copy->dummy_title($self->dummy_title || "");
2470 $copy->dummy_author($self->dummy_author || "");
2471 $copy->dummy_isbn($self->dummy_isbn || "");
2472 $copy->circ_modifier($self->circ_modifier);
2475 # See if we need to override the circ_lib for the copy with a configured circ_lib
2476 # Setting is shortname of the org unit
2477 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2478 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2480 if($precat_circ_lib) {
2481 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2484 $self->bail_on_events($self->editor->event);
2488 $copy->circ_lib($org->id);
2492 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2494 $self->push_events($self->editor->event);
2500 sub checkout_noncat {
2506 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2507 my $count = $self->noncat_count || 1;
2508 my $cotime = clean_ISO8601($self->checkout_time) || "";
2510 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2514 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2515 $self->editor->requestor->id,
2523 $self->push_events($evt);
2531 # if an item is in transit but the status doesn't agree, then we need to fix things.
2532 # The next two subs will hopefully do that
2533 sub fix_broken_transit_status {
2536 # Capture the transit so we don't have to fetch it again later during checkin
2537 # This used to live in sub check_transit_checkin_interval and later again in
2540 $self->editor->search_action_transit_copy(
2541 {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2545 if ($self->transit && $U->copy_status($self->copy->status)->id != OILS_COPY_STATUS_IN_TRANSIT) {
2546 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2547 " that is in-transit but without the In Transit status... fixing");
2548 $self->copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2549 # FIXME - do we want to make this permanent if the checkin bails?
2554 sub cancel_transit_if_circ_exists {
2556 if ($self->circ && $self->transit) {
2557 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2558 " that is in-transit AND circulating... aborting the transit");
2559 my $circ_ses = create OpenSRF::AppSession("open-ils.circ");
2560 my $result = $circ_ses->request(
2561 "open-ils.circ.transit.abort",
2562 $self->editor->authtoken,
2563 { 'transitid' => $self->transit->id }
2565 $logger->warn("circulator: transit abort result: ".$result);
2566 $circ_ses->disconnect;
2567 $self->transit(undef);
2571 # If a copy goes into transit and is then checked in before the transit checkin
2572 # interval has expired, push an event onto the overridable events list.
2573 sub check_transit_checkin_interval {
2576 # only concerned with in-transit items
2577 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2579 # no interval, no problem
2580 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2581 return unless $interval;
2583 # transit from X to X for whatever reason has no min interval
2584 return if $self->transit->source == $self->transit->dest;
2586 my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2587 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2588 my $horizon = $t_start->add(seconds => $seconds);
2590 # See if we are still within the transit checkin forbidden range
2591 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2592 if $horizon > DateTime->now;
2595 # Retarget local holds at checkin
2596 sub checkin_retarget {
2598 return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2599 return unless $self->is_checkin; # Renewals need not be checked
2600 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2601 return if $self->is_precat; # No holds for precats
2602 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2603 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2604 my $status = $U->copy_status($self->copy->status);
2605 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2606 # Specifically target items that are likely new (by status ID)
2607 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2608 my $location = $self->copy->location;
2609 if(!ref($location)) {
2610 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2611 $self->copy->location($location);
2613 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2615 # Fetch holds for the bib
2616 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2617 $self->editor->authtoken,
2620 capture_time => undef, # No touching captured holds
2621 frozen => 'f', # Don't bother with frozen holds
2622 pickup_lib => $self->circ_lib # Only holds actually here
2625 # Error? Skip the step.
2626 return if exists $result->{"ilsevent"};
2630 foreach my $holdlist (keys %{$result}) {
2631 push @$holds, @{$result->{$holdlist}};
2634 return if scalar(@$holds) == 0; # No holds, no retargeting
2636 # Check for parts on this copy
2637 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2638 my %parts_hash = ();
2639 %parts_hash = map {$_->part, 1} @$parts if @$parts;
2641 # Loop over holds in request-ish order
2642 # Stage 1: Get them into request-ish order
2643 # Also grab type and target for skipping low hanging ones
2644 $result = $self->editor->json_query({
2645 "select" => { "ahr" => ["id", "hold_type", "target"] },
2646 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2647 "where" => { "id" => $holds },
2649 { "class" => "pgt", "field" => "hold_priority"},
2650 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2651 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2652 { "class" => "ahr", "field" => "request_time"}
2657 if (ref $result eq "ARRAY" and scalar @$result) {
2658 foreach (@{$result}) {
2659 # Copy level, but not this copy?
2660 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2661 and $_->{target} != $self->copy->id);
2662 # Volume level, but not this volume?
2663 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2664 if(@$parts) { # We have parts?
2666 next if ($_->{hold_type} eq 'T');
2667 # Skip part holds for parts not on this copy
2668 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2670 # No parts, no part holds
2671 next if ($_->{hold_type} eq 'P');
2673 # So much for easy stuff, attempt a retarget!
2674 my $tresult = $U->simplereq(
2675 'open-ils.hold-targeter',
2676 'open-ils.hold-targeter.target',
2677 {hold => $_->{id}, find_copy => $self->copy->id}
2679 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2680 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2688 $self->log_me("do_checkin()");
2690 return $self->bail_on_events(
2691 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2694 $self->fix_broken_transit_status; # if applicable
2695 $self->check_transit_checkin_interval;
2696 $self->checkin_retarget;
2698 # the renew code and mk_env should have already found our circulation object
2699 unless( $self->circ ) {
2701 my $circs = $self->editor->search_action_circulation(
2702 { target_copy => $self->copy->id, checkin_time => undef });
2704 $self->circ($$circs[0]);
2706 # for now, just warn if there are multiple open circs on a copy
2707 $logger->warn("circulator: we have ".scalar(@$circs).
2708 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2710 $self->cancel_transit_if_circ_exists; # if applicable
2712 my $stat = $U->copy_status($self->copy->status)->id;
2714 # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2715 # differently if they are already paid for. We need to check for this
2716 # early since overdue generation is potentially affected.
2717 my $dont_change_lost_zero = 0;
2718 if ($stat == OILS_COPY_STATUS_LOST
2719 || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2720 || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2722 # LOST fine settings are controlled by the copy's circ lib, not the the
2724 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2725 $self->copy->circ_lib->id : $self->copy->circ_lib;
2726 $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2727 $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2728 $self->editor) || 0;
2730 # Don't assume there's always a circ based on copy status
2731 if ($dont_change_lost_zero && $self->circ) {
2732 my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2733 $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2736 $self->dont_change_lost_zero($dont_change_lost_zero);
2739 my $latest_inventory = Fieldmapper::asset::latest_inventory->new;
2741 if ($self->do_inventory_update) {
2742 $latest_inventory->inventory_date('now');
2743 $latest_inventory->inventory_workstation($self->editor->requestor->wsid);
2744 $latest_inventory->copy($self->copy->id());
2746 my $alci = $self->editor->search_asset_latest_inventory(
2747 {copy => $self->copy->id}
2749 $latest_inventory = $alci->[0]
2751 $self->latest_inventory($latest_inventory);
2753 if( $self->checkin_check_holds_shelf() ) {
2754 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2755 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2756 if($self->fake_hold_dest) {
2757 $self->hold->pickup_lib($self->circ_lib);
2759 $self->checkin_flesh_events;
2763 unless( $self->is_renewal ) {
2764 return $self->bail_on_events($self->editor->event)
2765 unless $self->editor->allowed('COPY_CHECKIN');
2768 $self->push_events($self->check_copy_alert());
2769 $self->push_events($self->check_checkin_copy_status());
2771 # if the circ is marked as 'claims returned', add the event to the list
2772 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2773 if ($self->circ and $self->circ->stop_fines
2774 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2776 $self->check_circ_deposit();
2778 # handle the overridable events
2779 $self->override_events unless $self->is_renewal;
2780 return if $self->bail_out;
2783 $self->checkin_handle_circ_start;
2784 return if $self->bail_out;
2786 if (!$dont_change_lost_zero) {
2787 # if this circ is LOST and we are configured to generate overdue
2788 # fines for lost items on checkin (to fill the gap between mark
2789 # lost time and when the fines would have naturally stopped), then
2790 # stop_fines is no longer valid and should be cleared.
2792 # stop_fines will be set again during the handle_fines() stage.
2793 # XXX should this setting come from the copy circ lib (like other
2794 # LOST settings), instead of the circulation circ lib?
2795 if ($stat == OILS_COPY_STATUS_LOST) {
2796 $self->circ->clear_stop_fines if
2797 $U->ou_ancestor_setting_value(
2799 OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2804 # Set stop_fines when claimed never checked out
2805 $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2807 # handle fines for this circ, including overdue gen if needed
2808 $self->handle_fines;
2811 $self->checkin_handle_circ_finish;
2812 return if $self->bail_out;
2813 $self->checkin_changed(1);
2815 } elsif( $self->transit ) {
2816 my $hold_transit = $self->process_received_transit;
2817 $self->checkin_changed(1);
2819 if( $self->bail_out ) {
2820 $self->checkin_flesh_events;
2824 if( my $e = $self->check_checkin_copy_status() ) {
2825 # If the original copy status is special, alert the caller
2826 my $ev = $self->events;
2827 $self->events([$e]);
2828 $self->override_events;
2829 return if $self->bail_out;
2833 if( $hold_transit or
2834 $U->copy_status($self->copy->status)->id
2835 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2838 if( $hold_transit ) {
2839 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2841 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2846 if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2848 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2849 $self->reshelve_copy(1);
2850 $self->cancelled_hold_transit(1);
2851 $self->notify_hold(0); # don't notify for cancelled holds
2852 $self->fake_hold_dest(0);
2853 return if $self->bail_out;
2855 } elsif ($hold and $hold->hold_type eq 'R') {
2857 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2858 $self->notify_hold(0); # No need to notify
2859 $self->fake_hold_dest(0);
2860 $self->noop(1); # Don't try and capture for other holds/transits now
2861 $self->update_copy();
2862 $hold->fulfillment_time('now');
2863 $self->bail_on_events($self->editor->event)
2864 unless $self->editor->update_action_hold_request($hold);
2868 # hold transited to correct location
2869 if($self->fake_hold_dest) {
2870 $hold->pickup_lib($self->circ_lib);
2872 $self->checkin_flesh_events;
2877 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2879 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2880 " that is in-transit, but there is no transit.. repairing");
2881 $self->reshelve_copy(1);
2882 return if $self->bail_out;
2885 if( $self->is_renewal ) {
2886 $self->finish_fines_and_voiding;
2887 return if $self->bail_out;
2888 $self->push_events(OpenILS::Event->new('SUCCESS'));
2892 # ------------------------------------------------------------------------------
2893 # Circulations and transits are now closed where necessary. Now go on to see if
2894 # this copy can fulfill a hold or needs to be routed to a different location
2895 # ------------------------------------------------------------------------------
2897 my $needed_for_something = 0; # formerly "needed_for_hold"
2899 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2901 if (!$self->remote_hold) {
2902 if ($self->use_booking) {
2903 my $potential_hold = $self->hold_capture_is_possible;
2904 my $potential_reservation = $self->reservation_capture_is_possible;
2906 if ($potential_hold and $potential_reservation) {
2907 $logger->info("circulator: item could fulfill either hold or reservation");
2908 $self->push_events(new OpenILS::Event(
2909 "HOLD_RESERVATION_CONFLICT",
2910 "hold" => $potential_hold,
2911 "reservation" => $potential_reservation
2913 return if $self->bail_out;
2914 } elsif ($potential_hold) {
2915 $needed_for_something =
2916 $self->attempt_checkin_hold_capture;
2917 } elsif ($potential_reservation) {
2918 $needed_for_something =
2919 $self->attempt_checkin_reservation_capture;
2922 $needed_for_something = $self->attempt_checkin_hold_capture;
2925 return if $self->bail_out;
2927 unless($needed_for_something) {
2928 my $circ_lib = (ref $self->copy->circ_lib) ?
2929 $self->copy->circ_lib->id : $self->copy->circ_lib;
2931 if( $self->remote_hold ) {
2932 $circ_lib = $self->remote_hold->pickup_lib;
2933 $logger->warn("circulator: Copy ".$self->copy->barcode.
2934 " is on a remote hold's shelf, sending to $circ_lib");
2937 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2939 my $suppress_transit = 0;
2941 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2942 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2943 if($suppress_transit_source && $suppress_transit_source->{value}) {
2944 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2945 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2946 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2947 $suppress_transit = 1;
2952 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2953 # copy is where it needs to be, either for hold or reshelving
2955 $self->checkin_handle_precat();
2956 return if $self->bail_out;
2959 # copy needs to transit "home", or stick here if it's a floating copy
2961 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2962 my $res = $self->editor->json_query(
2964 'evergreen.can_float',
2965 $self->copy->floating->id,
2966 $self->copy->circ_lib,
2971 $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res;
2973 if ($can_float) { # Yep, floating, stick here
2974 $self->checkin_changed(1);
2975 $self->copy->circ_lib( $self->circ_lib );
2978 my $bc = $self->copy->barcode;
2979 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2980 $self->checkin_build_copy_transit($circ_lib);
2981 return if $self->bail_out;
2982 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2986 } else { # no-op checkin
2987 if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2988 my $res = $self->editor->json_query(
2991 'evergreen.can_float',
2992 $self->copy->floating->id,
2993 $self->copy->circ_lib,
2998 if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2999 $self->checkin_changed(1);
3000 $self->copy->circ_lib( $self->circ_lib );
3006 if($self->claims_never_checked_out and
3007 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
3009 # the item was not supposed to be checked out to the user and should now be marked as missing
3010 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
3011 $self->copy->status($next_status);
3015 $self->reshelve_copy unless $needed_for_something;
3018 return if $self->bail_out;
3020 unless($self->checkin_changed) {
3022 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
3023 my $stat = $U->copy_status($self->copy->status)->id;
3025 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
3026 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
3027 $self->bail_out(1); # no need to commit anything
3031 $self->push_events(OpenILS::Event->new('SUCCESS'))
3032 unless @{$self->events};
3035 $self->finish_fines_and_voiding;
3037 OpenILS::Utils::Penalty->calculate_penalties(
3038 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3040 $self->checkin_flesh_events;
3044 sub finish_fines_and_voiding {
3046 return unless $self->circ;
3048 return unless $self->backdate or $self->void_overdues;
3050 # void overdues after fine generation to prevent concurrent DB access to overdue billings
3051 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3053 my $evt = $CC->void_or_zero_overdues(
3054 $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3056 return $self->bail_on_events($evt) if $evt;
3058 # Make sure the circ is open or closed as necessary.
3059 $evt = $U->check_open_xact($self->editor, $self->circ->id);
3060 return $self->bail_on_events($evt) if $evt;
3066 # if a deposit was payed for this item, push the event
3067 sub check_circ_deposit {
3069 return unless $self->circ;
3070 my $deposit = $self->editor->search_money_billing(
3072 xact => $self->circ->id,
3074 }, {idlist => 1})->[0];
3076 $self->push_events(OpenILS::Event->new(
3077 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
3082 my $force = $self->force || shift;
3083 my $copy = $self->copy;
3085 my $stat = $U->copy_status($copy->status)->id;
3087 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3090 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3091 $stat != OILS_COPY_STATUS_CATALOGING and
3092 $stat != OILS_COPY_STATUS_IN_TRANSIT and
3093 $stat != $next_status )) {
3095 $copy->status( $next_status );
3097 $self->checkin_changed(1);
3102 # Returns true if the item is at the current location
3103 # because it was transited there for a hold and the
3104 # hold has not been fulfilled
3105 sub checkin_check_holds_shelf {
3107 return 0 unless $self->copy;
3110 $U->copy_status($self->copy->status)->id ==
3111 OILS_COPY_STATUS_ON_HOLDS_SHELF;
3113 # Attempt to clear shelf expired holds for this copy
3114 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3115 if($self->clear_expired);
3117 # find the hold that put us on the holds shelf
3118 my $holds = $self->editor->search_action_hold_request(
3120 current_copy => $self->copy->id,
3121 capture_time => { '!=' => undef },
3122 fulfillment_time => undef,
3123 cancel_time => undef,
3128 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3129 $self->reshelve_copy(1);
3133 my $hold = $$holds[0];
3135 $logger->info("circulator: we found a captured, un-fulfilled hold [".
3136 $hold->id. "] for copy ".$self->copy->barcode);
3138 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3139 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3140 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3141 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3142 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3143 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3144 $self->fake_hold_dest(1);
3150 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3151 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3155 $logger->info("circulator: hold is not for here..");
3156 $self->remote_hold($hold);
3161 sub checkin_handle_precat {
3163 my $copy = $self->copy;
3165 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3166 $copy->status(OILS_COPY_STATUS_CATALOGING);
3167 $self->update_copy();
3168 $self->checkin_changed(1);
3169 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3174 sub checkin_build_copy_transit {
3177 my $copy = $self->copy;
3178 my $transit = Fieldmapper::action::transit_copy->new;
3180 # if we are transiting an item to the shelf shelf, it's a hold transit
3181 if (my $hold = $self->remote_hold) {
3182 $transit = Fieldmapper::action::hold_transit_copy->new;
3183 $transit->hold($hold->id);
3185 # the item is going into transit, remove any shelf-iness
3186 if ($hold->current_shelf_lib or $hold->shelf_time) {
3187 $hold->clear_current_shelf_lib;
3188 $hold->clear_shelf_time;
3189 return $self->bail_on_events($self->editor->event)
3190 unless $self->editor->update_action_hold_request($hold);
3194 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3195 $logger->info("circulator: transiting copy to $dest");
3197 $transit->source($self->circ_lib);
3198 $transit->dest($dest);
3199 $transit->target_copy($copy->id);
3200 $transit->source_send_time('now');
3201 $transit->copy_status( $U->copy_status($copy->status)->id );
3203 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3205 if ($self->remote_hold) {
3206 return $self->bail_on_events($self->editor->event)
3207 unless $self->editor->create_action_hold_transit_copy($transit);
3209 return $self->bail_on_events($self->editor->event)
3210 unless $self->editor->create_action_transit_copy($transit);
3213 # ensure the transit is returned to the caller
3214 $self->transit($transit);
3216 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3218 $self->checkin_changed(1);
3222 sub hold_capture_is_possible {
3224 my $copy = $self->copy;
3226 # we've been explicitly told not to capture any holds
3227 return 0 if $self->capture eq 'nocapture';
3229 # See if this copy can fulfill any holds
3230 my $hold = $holdcode->find_nearest_permitted_hold(
3231 $self->editor, $copy, $self->editor->requestor, 1 # check_only
3233 return undef if ref $hold eq "HASH" and
3234 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3238 sub reservation_capture_is_possible {
3240 my $copy = $self->copy;
3242 # we've been explicitly told not to capture any holds
3243 return 0 if $self->capture eq 'nocapture';
3245 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3246 my $resv = $booking_ses->request(
3247 "open-ils.booking.reservations.could_capture",
3248 $self->editor->authtoken, $copy->barcode
3250 $booking_ses->disconnect;
3251 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3252 $self->push_events($resv);
3258 # returns true if the item was used (or may potentially be used
3259 # in subsequent calls) to capture a hold.
3260 sub attempt_checkin_hold_capture {
3262 my $copy = $self->copy;
3264 # we've been explicitly told not to capture any holds
3265 return 0 if $self->capture eq 'nocapture';
3267 # See if this copy can fulfill any holds
3268 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3269 $self->editor, $copy, $self->editor->requestor );
3272 $logger->debug("circulator: no potential permitted".
3273 "holds found for copy ".$copy->barcode);
3277 if($self->capture ne 'capture') {
3278 # see if this item is in a hold-capture-delay location
3279 my $location = $self->copy->location;
3280 if(!ref($location)) {
3281 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3282 $self->copy->location($location);
3284 if($U->is_true($location->hold_verify)) {
3285 $self->bail_on_events(
3286 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3291 $self->retarget($retarget);
3293 my $suppress_transit = 0;
3294 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3295 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3296 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3297 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3298 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3299 $suppress_transit = 1;
3300 $hold->pickup_lib($self->circ_lib);
3305 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3307 $hold->current_copy($copy->id);
3308 $hold->capture_time('now');
3309 $self->put_hold_on_shelf($hold)
3310 if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3312 # prevent DB errors caused by fetching
3313 # holds from storage, and updating through cstore
3314 $hold->clear_fulfillment_time;
3315 $hold->clear_fulfillment_staff;
3316 $hold->clear_fulfillment_lib;
3317 $hold->clear_expire_time;
3318 $hold->clear_cancel_time;
3319 $hold->clear_prev_check_time unless $hold->prev_check_time;
3321 $self->bail_on_events($self->editor->event)
3322 unless $self->editor->update_action_hold_request($hold);
3324 $self->checkin_changed(1);
3326 return 0 if $self->bail_out;
3328 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3330 if ($hold->hold_type eq 'R') {
3331 $copy->status(OILS_COPY_STATUS_CATALOGING);
3332 $hold->fulfillment_time('now');
3333 $self->noop(1); # Block other transit/hold checks
3334 $self->bail_on_events($self->editor->event)
3335 unless $self->editor->update_action_hold_request($hold);
3337 # This hold was captured in the correct location
3338 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3339 $self->push_events(OpenILS::Event->new('SUCCESS'));
3341 #$self->do_hold_notify($hold->id);
3342 $self->notify_hold($hold->id);
3347 # Hold needs to be picked up elsewhere. Build a hold
3348 # transit and route the item.
3349 $self->checkin_build_hold_transit();
3350 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3351 return 0 if $self->bail_out;
3352 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3355 # make sure we save the copy status
3357 return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3361 sub attempt_checkin_reservation_capture {
3363 my $copy = $self->copy;
3365 # we've been explicitly told not to capture any holds
3366 return 0 if $self->capture eq 'nocapture';
3368 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3369 my $evt = $booking_ses->request(
3370 "open-ils.booking.resources.capture_for_reservation",
3371 $self->editor->authtoken,
3373 1 # don't update copy - we probably have it locked
3375 $booking_ses->disconnect;
3377 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3379 "open-ils.booking.resources.capture_for_reservation " .
3380 "didn't return an event!"
3384 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3385 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3387 # not-transferable is an error event we'll pass on the user
3388 $logger->warn("reservation capture attempted against non-transferable item");
3389 $self->push_events($evt);
3391 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3392 # Re-retrieve copy as reservation capture may have changed
3393 # its status and whatnot.
3395 "circulator: booking capture win on copy " . $self->copy->id
3397 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3399 "circulator: changing copy " . $self->copy->id .
3400 "'s status from " . $self->copy->status . " to " .
3403 $self->copy->status($new_copy_status);
3406 $self->reservation($evt->{"payload"}->{"reservation"});
3408 if (exists $evt->{"payload"}->{"transit"}) {
3412 "org" => $evt->{"payload"}->{"transit"}->dest
3416 $self->checkin_changed(1);
3420 # other results are treated as "nothing to capture"
3424 sub do_hold_notify {
3425 my( $self, $holdid ) = @_;
3427 my $e = new_editor(xact => 1);
3428 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3430 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3431 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3433 $logger->info("circulator: running delayed hold notify process");
3435 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3436 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3438 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3439 hold_id => $holdid, requestor => $self->editor->requestor);
3441 $logger->debug("circulator: built hold notifier");
3443 if(!$notifier->event) {
3445 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3447 my $stat = $notifier->send_email_notify;
3448 if( $stat == '1' ) {
3449 $logger->info("circulator: hold notify succeeded for hold $holdid");
3453 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3456 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3460 sub retarget_holds {
3462 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3463 my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3464 $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3465 # no reason to wait for the return value
3469 sub checkin_build_hold_transit {
3472 my $copy = $self->copy;
3473 my $hold = $self->hold;
3474 my $trans = Fieldmapper::action::hold_transit_copy->new;
3476 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3478 $trans->hold($hold->id);
3479 $trans->source($self->circ_lib);
3480 $trans->dest($hold->pickup_lib);
3481 $trans->source_send_time("now");
3482 $trans->target_copy($copy->id);
3484 # when the copy gets to its destination, it will recover
3485 # this status - put it onto the holds shelf
3486 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3488 return $self->bail_on_events($self->editor->event)
3489 unless $self->editor->create_action_hold_transit_copy($trans);
3494 sub process_received_transit {
3496 my $copy = $self->copy;
3497 my $copyid = $self->copy->id;
3499 my $status_name = $U->copy_status($copy->status)->name;
3500 $logger->debug("circulator: attempting transit receive on ".
3501 "copy $copyid. Copy status is $status_name");
3503 my $transit = $self->transit;
3505 # Check if we are in a transit suppress range
3506 my $suppress_transit = 0;
3507 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3508 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3509 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3510 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3511 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3512 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3513 $suppress_transit = 1;
3514 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3518 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3519 # - this item is in-transit to a different location
3520 # - Or we are capturing holds as transits, so why create a new transit?
3522 my $tid = $transit->id;
3523 my $loc = $self->circ_lib;
3524 my $dest = $transit->dest;
3526 $logger->info("circulator: Fowarding transit on copy which is destined ".
3527 "for a different location. transit=$tid, copy=$copyid, current ".
3528 "location=$loc, destination location=$dest");
3530 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3532 # grab the associated hold object if available
3533 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3534 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3536 return $self->bail_on_events($evt);
3539 # The transit is received, set the receive time
3540 $transit->dest_recv_time('now');
3541 $self->bail_on_events($self->editor->event)
3542 unless $self->editor->update_action_transit_copy($transit);
3544 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3546 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3547 $copy->status( $transit->copy_status );
3548 $self->update_copy();
3549 return if $self->bail_out;
3553 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3556 # hold has arrived at destination, set shelf time
3557 $self->put_hold_on_shelf($hold);
3558 $self->bail_on_events($self->editor->event)
3559 unless $self->editor->update_action_hold_request($hold);
3560 return if $self->bail_out;
3562 $self->notify_hold($hold_transit->hold);
3565 $hold_transit = undef;
3566 $self->cancelled_hold_transit(1);
3567 $self->reshelve_copy(1);
3568 $self->fake_hold_dest(0);
3573 OpenILS::Event->new(
3576 payload => { transit => $transit, holdtransit => $hold_transit } ));
3578 return $hold_transit;
3582 # ------------------------------------------------------------------
3583 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3584 # ------------------------------------------------------------------
3585 sub put_hold_on_shelf {
3586 my($self, $hold) = @_;
3587 $hold->shelf_time('now');
3588 $hold->current_shelf_lib($self->circ_lib);
3589 $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3595 my $reservation = shift;
3596 my $dt_parser = DateTime::Format::ISO8601->new;
3598 my $obj = $reservation ? $self->reservation : $self->circ;
3600 my $lost_bill_opts = $self->lost_bill_options;
3601 my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3602 # first, restore any voided overdues for lost, if needed
3603 if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3604 my $restore_od = $U->ou_ancestor_setting_value(
3605 $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3606 $self->editor) || 0;
3607 $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3611 # next, handle normal overdue generation and apply stop_fines
3612 # XXX reservations don't have stop_fines
3613 # TODO revisit booking_reservation re: stop_fines support
3614 if ($reservation or !$obj->stop_fines) {
3617 # This is a crude check for whether we are in a grace period. The code
3618 # in generate_fines() does a more thorough job, so this exists solely
3619 # as a small optimization, and might be better off removed.
3621 # If we have a grace period
3622 if($obj->can('grace_period')) {
3623 # Parse out the due date
3624 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($obj->due_date) );
3625 # Add the grace period to the due date
3626 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($obj->grace_period));
3627 # Don't generate fines on circs still in grace period
3628 $skip_for_grace = $due_date > DateTime->now;
3630 $CC->generate_fines({circs => [$obj], editor => $self->editor})
3631 unless $skip_for_grace;
3633 if (!$reservation and !$obj->stop_fines) {
3634 $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3635 $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3636 $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3637 $obj->stop_fines_time('now');
3638 $obj->stop_fines_time($self->backdate) if $self->backdate;
3639 $self->editor->update_action_circulation($obj);
3643 # finally, handle voiding of lost item and processing fees
3644 if ($self->needs_lost_bill_handling) {
3645 my $void_cost = $U->ou_ancestor_setting_value(
3646 $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3647 $self->editor) || 0;
3648 my $void_proc_fee = $U->ou_ancestor_setting_value(
3649 $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3650 $self->editor) || 0;
3651 $self->checkin_handle_lost_or_lo_now_found(
3652 $lost_bill_opts->{void_cost_btype},
3653 $lost_bill_opts->{is_longoverdue}) if $void_cost;
3654 $self->checkin_handle_lost_or_lo_now_found(
3655 $lost_bill_opts->{void_fee_btype},
3656 $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3662 sub checkin_handle_circ_start {
3664 my $circ = $self->circ;
3665 my $copy = $self->copy;
3669 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3671 # backdate the circ if necessary
3672 if($self->backdate) {
3673 my $evt = $self->checkin_handle_backdate;
3674 return $self->bail_on_events($evt) if $evt;
3677 # Set the checkin vars since we have the item
3678 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3680 # capture the true scan time for back-dated checkins
3681 $circ->checkin_scan_time('now');
3683 $circ->checkin_staff($self->editor->requestor->id);
3684 $circ->checkin_lib($self->circ_lib);
3685 $circ->checkin_workstation($self->editor->requestor->wsid);
3687 my $circ_lib = (ref $self->copy->circ_lib) ?
3688 $self->copy->circ_lib->id : $self->copy->circ_lib;
3689 my $stat = $U->copy_status($self->copy->status)->id;
3691 if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3692 # we will now handle lost fines, but the copy will retain its 'lost'
3693 # status if it needs to transit home unless lost_immediately_available
3696 # if we decide to also delay fine handling until the item arrives home,
3697 # we will need to call lost fine handling code both when checking items
3698 # in and also when receiving transits
3699 $self->checkin_handle_lost($circ_lib);
3700 } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3701 # same process as above.
3702 $self->checkin_handle_long_overdue($circ_lib);
3703 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3704 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3706 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3707 $self->copy->status($U->copy_status($next_status));
3714 sub checkin_handle_circ_finish {
3716 my $e = $self->editor;
3717 my $circ = $self->circ;
3719 # Do one last check before the final circulation update to see
3720 # if the xact_finish value should be set or not.
3722 # The underlying money.billable_xact may have been updated to
3723 # reflect a change in xact_finish during checkin bills handling,
3724 # however we can't simply refresh the circulation from the DB,
3725 # because other changes may be pending. Instead, reproduce the
3726 # xact_finish check here. It won't hurt to do it again.
3728 my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3729 if ($sum) { # is this test still needed?
3731 my $balance = $sum->balance_owed;
3733 if ($balance == 0) {
3734 $circ->xact_finish('now');
3736 $circ->clear_xact_finish;
3739 $logger->info("circulator: $balance is owed on this circulation");
3742 return $self->bail_on_events($e->event)
3743 unless $e->update_action_circulation($circ);
3748 # ------------------------------------------------------------------
3749 # See if we need to void billings, etc. for lost checkin
3750 # ------------------------------------------------------------------
3751 sub checkin_handle_lost {
3753 my $circ_lib = shift;
3755 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3756 OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3758 $self->lost_bill_options({
3759 circ_lib => $circ_lib,
3760 ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3761 ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3762 ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3763 void_cost_btype => 3,
3767 return $self->checkin_handle_lost_or_longoverdue(
3768 circ_lib => $circ_lib,
3769 max_return => $max_return,
3770 ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3771 ous_use_last_activity => undef # not supported for LOST checkin
3775 # ------------------------------------------------------------------
3776 # See if we need to void billings, etc. for long-overdue checkin
3777 # note: not using constants below since they serve little purpose
3778 # for single-use strings that are descriptive in their own right
3779 # and mostly just complicate debugging.
3780 # ------------------------------------------------------------------
3781 sub checkin_handle_long_overdue {
3783 my $circ_lib = shift;
3785 $logger->info("circulator: processing long-overdue checkin...");
3787 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3788 'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3790 $self->lost_bill_options({
3791 circ_lib => $circ_lib,
3792 ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3793 ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3794 is_longoverdue => 1,
3795 ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3796 void_cost_btype => 10,
3797 void_fee_btype => 11
3800 return $self->checkin_handle_lost_or_longoverdue(
3801 circ_lib => $circ_lib,
3802 max_return => $max_return,
3803 ous_immediately_available => 'circ.longoverdue_immediately_available',
3804 ous_use_last_activity =>
3805 'circ.longoverdue.use_last_activity_date_on_return'
3809 # last billing activity is last payment time, last billing time, or the
3810 # circ due date. If the relevant "use last activity" org unit setting is
3811 # false/unset, then last billing activity is always the due date.
3812 sub get_circ_last_billing_activity {
3814 my $circ_lib = shift;
3815 my $setting = shift;
3816 my $date = $self->circ->due_date;
3818 return $date unless $setting and
3819 $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3821 my $xact = $self->editor->retrieve_money_billable_transaction([
3823 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3826 if ($xact->summary) {
3827 $date = $xact->summary->last_payment_ts ||
3828 $xact->summary->last_billing_ts ||
3829 $self->circ->due_date;
3836 sub checkin_handle_lost_or_longoverdue {
3837 my ($self, %args) = @_;
3839 my $circ = $self->circ;
3840 my $max_return = $args{max_return};
3841 my $circ_lib = $args{circ_lib};
3846 $self->get_circ_last_billing_activity(
3847 $circ_lib, $args{ous_use_last_activity});
3850 my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3851 $tm[5] -= 1 if $tm[5] > 0;
3852 my $due = timelocal(int($tm[1]), int($tm[2]),
3853 int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3856 OpenILS::Utils::DateTime->interval_to_seconds($max_return) + int($due);
3858 $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3859 "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3860 "DUE: $due LAST: $last_chance");
3862 $max_return = 0 if $today < $last_chance;
3868 $logger->info("circulator: check-in of lost/lo item exceeds max ".
3869 "return interval. skipping fine/fee voiding, etc.");
3871 } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3873 $logger->info("circulator: check-in of lost/lo item having a balance ".
3874 "of zero, skipping fine/fee voiding and reinstatement.");
3876 } else { # within max-return interval or no interval defined
3878 $logger->info("circulator: check-in of lost/lo item is within the ".
3879 "max return interval (or no interval is defined). Proceeding ".
3880 "with fine/fee voiding, etc.");
3882 $self->needs_lost_bill_handling(1);
3885 if ($circ_lib != $self->circ_lib) {
3886 # if the item is not home, check to see if we want to retain the
3887 # lost/longoverdue status at this point in the process
3889 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib,
3890 $args{ous_immediately_available}, $self->editor) || 0;
3892 if ($immediately_available) {
3893 # item status does not need to be retained, so give it a
3894 # reshelving status as if it were a normal checkin
3895 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3896 $self->copy->status($U->copy_status($next_status));
3899 $logger->info("circulator: leaving lost/longoverdue copy".
3900 " status in place on checkin");
3903 # lost/longoverdue item is home and processed, treat like a normal
3904 # checkin from this point on
3905 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3906 $self->copy->status($U->copy_status($next_status));
3912 sub checkin_handle_backdate {
3915 # ------------------------------------------------------------------
3916 # clean up the backdate for date comparison
3917 # XXX We are currently taking the due-time from the original due-date,
3918 # not the input. Do we need to do this? This certainly interferes with
3919 # backdating of hourly checkouts, but that is likely a very rare case.
3920 # ------------------------------------------------------------------
3921 my $bd = clean_ISO8601($self->backdate);
3922 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->circ->due_date));
3923 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3924 $new_date->set_hour($original_date->hour());
3925 $new_date->set_minute($original_date->minute());
3926 if ($new_date >= DateTime->now) {
3927 # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3928 # $self->backdate() autoload handler ignores undef values.
3929 # Clear the backdate manually.
3930 $logger->info("circulator: ignoring future backdate: $new_date");
3931 delete $self->{backdate};
3933 $self->backdate(clean_ISO8601($new_date->datetime()));
3940 sub check_checkin_copy_status {
3942 my $copy = $self->copy;
3944 my $status = $U->copy_status($copy->status)->id;
3947 if( $self->new_copy_alerts ||
3948 $status == OILS_COPY_STATUS_AVAILABLE ||
3949 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3950 $status == OILS_COPY_STATUS_IN_PROCESS ||
3951 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3952 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3953 $status == OILS_COPY_STATUS_CATALOGING ||
3954 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3955 $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
3956 $status == OILS_COPY_STATUS_RESHELVING );
3958 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3959 if( $status == OILS_COPY_STATUS_LOST );
3961 return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
3962 if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
3964 return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
3965 if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
3967 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3968 if( $status == OILS_COPY_STATUS_MISSING );
3970 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3975 # --------------------------------------------------------------------------
3976 # On checkin, we need to return as many relevant objects as we can
3977 # --------------------------------------------------------------------------
3978 sub checkin_flesh_events {
3981 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3982 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3983 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3986 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3989 if($self->hold and !$self->hold->cancel_time) {
3990 $hold = $self->hold;
3991 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3995 # update our copy of the circ object and
3996 # flesh the billing summary data
3998 $self->editor->retrieve_action_circulation([
4002 circ => ['billable_transaction'],
4011 # flesh some patron fields before returning
4013 $self->editor->retrieve_actor_user([
4018 au => ['card', 'billing_address', 'mailing_address']
4025 if ($self->latest_inventory) {
4026 # flesh some workstation fields before returning
4027 $self->latest_inventory->inventory_workstation(
4028 $self->editor->retrieve_actor_workstation([$self->latest_inventory->inventory_workstation])
4032 if($self->latest_inventory && !$self->latest_inventory->id) {
4033 my $alci = $self->editor->search_asset_latest_inventory(
4034 {copy => $self->latest_inventory->copy}
4037 $self->latest_inventory->id($alci->[0]->id);
4040 $self->copy->latest_inventory($self->latest_inventory);
4042 for my $evt (@{$self->events}) {
4045 $payload->{copy} = $U->unflesh_copy($self->copy);
4046 $payload->{volume} = $self->volume;
4047 $payload->{record} = $record,
4048 $payload->{circ} = $self->circ;
4049 $payload->{transit} = $self->transit;
4050 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
4051 $payload->{hold} = $hold;
4052 $payload->{patron} = $self->patron;
4053 $payload->{reservation} = $self->reservation
4054 unless (not $self->reservation or $self->reservation->cancel_time);
4055 $payload->{latest_inventory} = $self->latest_inventory;
4056 if ($self->do_inventory_update) { $payload->{do_inventory_update} = 1; }
4058 $evt->{payload} = $payload;
4063 my( $self, $msg ) = @_;
4064 my $bc = ($self->copy) ? $self->copy->barcode :
4067 my $usr = ($self->patron) ? $self->patron->id : "";
4068 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
4069 ", recipient=$usr, copy=$bc");
4076 $self->log_me("do_renew()");
4078 # Make sure there is an open circ to renew
4079 my $usrid = $self->patron->id if $self->patron;
4080 my $circ = $self->editor->search_action_circulation({
4081 target_copy => $self->copy->id,
4082 xact_finish => undef,
4083 checkin_time => undef,
4084 ($usrid ? (usr => $usrid) : ())
4087 return $self->bail_on_events($self->editor->event) unless $circ;
4089 # A user is not allowed to renew another user's items without permission
4090 unless( $circ->usr eq $self->editor->requestor->id ) {
4091 return $self->bail_on_events($self->editor->events)
4092 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
4095 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
4096 if $circ->renewal_remaining < 1;
4098 $self->push_events(OpenILS::Event->new('MAX_AUTO_RENEWALS_REACHED'))
4099 if $self->auto_renewal and $circ->auto_renewal_remaining < 1;
4100 # -----------------------------------------------------------------
4102 $self->parent_circ($circ->id);
4103 $self->renewal_remaining( $circ->renewal_remaining - 1 );
4104 $self->auto_renewal_remaining( $circ->auto_renewal_remaining - 1 ) if (defined($circ->auto_renewal_remaining));
4107 # Opac renewal - re-use circ library from original circ (unless told not to)
4108 if($self->opac_renewal or $self->auto_renewal) {
4109 unless(defined($opac_renewal_use_circ_lib)) {
4110 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
4111 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4112 $opac_renewal_use_circ_lib = 1;
4115 $opac_renewal_use_circ_lib = 0;
4118 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4121 # Desk renewal - re-use circ library from original circ (unless told not to)
4122 if($self->desk_renewal) {
4123 unless(defined($desk_renewal_use_circ_lib)) {
4124 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4125 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4126 $desk_renewal_use_circ_lib = 1;
4129 $desk_renewal_use_circ_lib = 0;
4132 $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
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)