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",
125 notes => <<" NOTES");
126 PARAMS( authtoken, circ => circ_id );
127 open-ils.circ.renew(login_session, circ_object);
128 Renews the provided circulation. login_session is the requestor of the
129 renewal and if the logged in user is not the same as circ->usr, then
130 the logged in user must have RENEW_CIRC permissions.
133 __PACKAGE__->register_method(
134 method => "run_method",
135 api_name => "open-ils.circ.checkout.full"
137 __PACKAGE__->register_method(
138 method => "run_method",
139 api_name => "open-ils.circ.checkout.full.override"
141 __PACKAGE__->register_method(
142 method => "run_method",
143 api_name => "open-ils.circ.reservation.pickup"
145 __PACKAGE__->register_method(
146 method => "run_method",
147 api_name => "open-ils.circ.reservation.return"
149 __PACKAGE__->register_method(
150 method => "run_method",
151 api_name => "open-ils.circ.reservation.return.override"
153 __PACKAGE__->register_method(
154 method => "run_method",
155 api_name => "open-ils.circ.checkout.inspect",
156 desc => q/Returns the circ matrix test result and, on success, the rule set and matrix test object/
161 my( $self, $conn, $auth, $args ) = @_;
162 translate_legacy_args($args);
163 $args->{override_args} = { all => 1 } unless defined $args->{override_args};
164 $args->{new_copy_alerts} ||= $self->api_level > 1 ? 1 : 0;
165 my $api = $self->api_name;
168 OpenILS::Application::Circ::Circulator->new($auth, %$args);
170 return circ_events($circulator) if $circulator->bail_out;
172 $circulator->use_booking(determine_booking_status());
174 # --------------------------------------------------------------------------
175 # First, check for a booking transit, as the barcode may not be a copy
176 # barcode, but a resource barcode, and nothing else in here will work
177 # --------------------------------------------------------------------------
179 if ($circulator->use_booking && (my $bc = $circulator->copy_barcode) && $api !~ /checkout|inspect/) { # do we have a barcode?
180 my $resources = $circulator->editor->search_booking_resource( { barcode => $bc } ); # any resources by this barcode?
181 if (@$resources) { # yes!
183 my $res_id_list = [ map { $_->id } @$resources ];
184 my $transit = $circulator->editor->search_action_reservation_transit_copy(
186 { target_copy => $res_id_list, dest => $circulator->circ_lib, dest_recv_time => undef, cancel_time => undef },
187 { order_by => { artc => 'source_send_time' }, limit => 1 }
189 )->[0]; # Any transit for this barcode?
191 if ($transit) { # yes! unwrap it.
193 my $reservation = $circulator->editor->retrieve_booking_reservation( $transit->reservation );
194 my $res_type = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
196 my $success_event = new OpenILS::Event(
197 "SUCCESS", "payload" => {"reservation" => $reservation}
199 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
200 if (my $copy = $circulator->editor->search_asset_copy([
201 { barcode => $bc, deleted => 'f' }, $MK_ENV_FLESH
202 ])->[0]) { # got a copy
203 $copy->status( $transit->copy_status );
204 $copy->editor($circulator->editor->requestor->id);
205 $copy->edit_date('now');
206 $circulator->editor->update_asset_copy($copy);
207 $success_event->{"payload"}->{"record"} =
208 $U->record_to_mvr($copy->call_number->record);
209 $success_event->{"payload"}->{"volume"} = $copy->call_number;
210 $copy->call_number($copy->call_number->id);
211 $success_event->{"payload"}->{"copy"} = $copy;
215 $transit->dest_recv_time('now');
216 $circulator->editor->update_action_reservation_transit_copy( $transit );
218 $circulator->editor->commit;
219 # Formerly this branch just stopped here. Argh!
220 $conn->respond_complete($success_event);
226 if ($circulator->use_booking) {
227 $circulator->is_res_checkin($circulator->is_checkin(1))
228 if $api =~ /reservation.return/ or (
229 $api =~ /checkin/ and $circulator->seems_like_reservation()
232 $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
235 $circulator->is_renewal(1) if $api =~ /renew/;
236 $circulator->is_checkin(1) if $api =~ /checkin/;
237 $circulator->is_checkout(1) if $api =~ /checkout/;
238 $circulator->override(1) if $api =~ /override/o;
240 $circulator->mk_env();
241 $circulator->noop(1) if $circulator->claims_never_checked_out;
243 return circ_events($circulator) if $circulator->bail_out;
245 if( $api =~ /checkout\.permit/ ) {
246 $circulator->do_permit();
248 } elsif( $api =~ /checkout.full/ ) {
250 # requesting a precat checkout implies that any required
251 # overrides have been performed. Go ahead and re-override.
252 $circulator->skip_permit_key(1);
253 $circulator->override(1) if ( $circulator->request_precat && $circulator->editor->allowed('CREATE_PRECAT') );
254 $circulator->do_permit();
255 $circulator->is_checkout(1);
256 unless( $circulator->bail_out ) {
257 $circulator->events([]);
258 $circulator->do_checkout();
261 } elsif( $circulator->is_res_checkout ) {
262 $circulator->do_reservation_pickup();
264 } elsif( $api =~ /inspect/ ) {
265 my $data = $circulator->do_inspect();
266 $circulator->editor->rollback;
269 } elsif( $api =~ /checkout/ ) {
270 $circulator->do_checkout();
272 } elsif( $circulator->is_res_checkin ) {
273 $circulator->do_reservation_return();
274 $circulator->do_checkin() if ($circulator->copy());
275 } elsif( $api =~ /checkin/ ) {
276 $circulator->do_checkin();
278 } elsif( $api =~ /renew/ ) {
279 $circulator->do_renew($api);
282 if( $circulator->bail_out ) {
285 # make sure no success event accidentally slip in
287 [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
290 my @e = @{$circulator->events};
291 push( @ee, $_->{textcode} ) for @e;
292 $logger->info("circulator: bailing out with events: " . (join ", ", @ee));
294 $circulator->editor->rollback;
298 # checkin and reservation return can result in modifications to
299 # actor.usr.claims_never_checked_out_count without also modifying
300 # actor.last_xact_id. Perform a no-op update on the patron to
301 # force an update to last_xact_id.
302 if ($circulator->claims_never_checked_out && $circulator->patron) {
303 $circulator->editor->update_actor_user(
304 $circulator->editor->retrieve_actor_user($circulator->patron->id))
305 or return $circulator->editor->die_event;
308 $circulator->editor->commit;
311 $conn->respond_complete(circ_events($circulator));
313 return undef if $circulator->bail_out;
315 $circulator->do_hold_notify($circulator->notify_hold)
316 if $circulator->notify_hold;
317 $circulator->retarget_holds if $circulator->retarget;
318 $circulator->append_reading_list;
319 $circulator->make_trigger_events;
326 my @e = @{$circ->events};
327 # if we have multiple events, SUCCESS should not be one of them;
328 @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
329 return (@e == 1) ? $e[0] : \@e;
333 sub translate_legacy_args {
336 if( $$args{barcode} ) {
337 $$args{copy_barcode} = $$args{barcode};
338 delete $$args{barcode};
341 if( $$args{copyid} ) {
342 $$args{copy_id} = $$args{copyid};
343 delete $$args{copyid};
346 if( $$args{patronid} ) {
347 $$args{patron_id} = $$args{patronid};
348 delete $$args{patronid};
351 if( $$args{patron} and !ref($$args{patron}) ) {
352 $$args{patron_id} = $$args{patron};
353 delete $$args{patron};
357 if( $$args{noncat} ) {
358 $$args{is_noncat} = $$args{noncat};
359 delete $$args{noncat};
362 if( $$args{precat} ) {
363 $$args{is_precat} = $$args{request_precat} = $$args{precat};
364 delete $$args{precat};
370 # --------------------------------------------------------------------------
371 # This package actually manages all of the circulation logic
372 # --------------------------------------------------------------------------
373 package OpenILS::Application::Circ::Circulator;
374 use strict; use warnings;
375 use vars q/$AUTOLOAD/;
377 use OpenILS::Utils::Fieldmapper;
378 use OpenSRF::Utils::Cache;
379 use Digest::MD5 qw(md5_hex);
380 use DateTime::Format::ISO8601;
381 use OpenILS::Utils::PermitHold;
382 use OpenILS::Utils::DateTime qw/:datetime/;
383 use OpenSRF::Utils::SettingsClient;
384 use OpenILS::Application::Circ::Holds;
385 use OpenILS::Application::Circ::Transit;
386 use OpenSRF::Utils::Logger qw(:logger);
387 use OpenILS::Utils::CStoreEditor qw/:funcs/;
388 use OpenILS::Const qw/:const/;
389 use OpenILS::Utils::Penalty;
390 use OpenILS::Application::Circ::CircCommon;
393 my $CC = "OpenILS::Application::Circ::CircCommon";
394 my $holdcode = "OpenILS::Application::Circ::Holds";
395 my $transcode = "OpenILS::Application::Circ::Transit";
401 # --------------------------------------------------------------------------
402 # Add a pile of automagic getter/setter methods
403 # --------------------------------------------------------------------------
404 my @AUTOLOAD_FIELDS = qw/
417 overrides_per_copy_alerts
458 recurring_fines_level
463 auto_renewal_remaining
472 cancelled_hold_transit
480 circ_matrix_matchpoint
491 claims_never_checked_out
504 dont_change_lost_zero
506 needs_lost_bill_handling
512 my $type = ref($self) or die "$self is not an object";
514 my $name = $AUTOLOAD;
517 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
518 $logger->error("circulator: $type: invalid autoload field: $name");
519 die "$type: invalid autoload field: $name\n"
524 *{"${type}::${name}"} = sub {
527 $s->{$name} = $v if defined $v;
531 return $self->$name($data);
536 my( $class, $auth, %args ) = @_;
537 $class = ref($class) || $class;
538 my $self = bless( {}, $class );
541 $self->editor(new_editor(xact => 1, authtoken => $auth));
543 unless( $self->editor->checkauth ) {
544 $self->bail_on_events($self->editor->event);
548 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
550 $self->$_($args{$_}) for keys %args;
553 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
555 # if this is a renewal, default to desk_renewal
556 $self->desk_renewal(1) unless
557 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal
558 or $self->auto_renewal;
560 $self->capture('') unless $self->capture;
562 unless(%user_groups) {
563 my $gps = $self->editor->retrieve_all_permission_grp_tree;
564 %user_groups = map { $_->id => $_ } @$gps;
571 # --------------------------------------------------------------------------
572 # True if we should discontinue processing
573 # --------------------------------------------------------------------------
575 my( $self, $bool ) = @_;
576 if( defined $bool ) {
577 $logger->info("circulator: BAILING OUT") if $bool;
578 $self->{bail_out} = $bool;
580 return $self->{bail_out};
585 my( $self, @evts ) = @_;
588 $e->{payload} = $self->copy if
589 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
591 $logger->info("circulator: pushing event ".$e->{textcode});
592 push( @{$self->events}, $e ) unless
593 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
599 return '' if $self->skip_permit_key;
600 my $key = md5_hex( time() . rand() . "$$" );
601 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
602 return $self->permit_key($key);
605 sub check_permit_key {
607 return 1 if $self->skip_permit_key;
608 my $key = $self->permit_key;
609 return 0 unless $key;
610 my $k = "oils_permit_key_$key";
611 my $one = $self->cache_handle->get_cache($k);
612 $self->cache_handle->delete_cache($k);
613 return ($one) ? 1 : 0;
616 sub seems_like_reservation {
619 # Some words about the following method:
620 # 1) It requires the VIEW_USER permission, but that's not an
621 # issue, right, since all staff should have that?
622 # 2) It returns only one reservation at a time, even if an item can be
623 # and is currently overbooked. Hmmm....
624 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
625 my $result = $booking_ses->request(
626 "open-ils.booking.reservations.by_returnable_resource_barcode",
627 $self->editor->authtoken,
630 $booking_ses->disconnect;
632 return $self->bail_on_events($result) if defined $U->event_code($result);
635 $self->reservation(shift @$result);
643 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
644 sub save_trimmed_copy {
645 my ($self, $copy) = @_;
648 $self->volume($copy->call_number);
649 $self->title($self->volume->record);
650 $self->copy->call_number($self->volume->id);
651 $self->volume->record($self->title->id);
652 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
653 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
654 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
655 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
659 sub collect_user_copy_alerts {
661 my $e = $self->editor;
664 my $alerts = $e->search_asset_copy_alert([
665 {copy => $self->copy->id, ack_time => undef},
666 {flesh => 1, flesh_fields => { aca => [ qw/ alert_type / ] }}
668 if (ref $alerts eq "ARRAY") {
669 $logger->info("circulator: found " . scalar(@$alerts) . " alerts for copy " .
671 $self->user_copy_alerts($alerts);
676 sub filter_user_copy_alerts {
679 my $e = $self->editor;
681 if(my $alerts = $self->user_copy_alerts) {
683 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
684 my $suppressions = $e->search_actor_copy_alert_suppress(
685 {org => $suppress_orgs}
689 foreach my $a (@$alerts) {
690 # filter on event type
691 if (defined $a->alert_type) {
692 next if ($a->alert_type->event eq 'CHECKIN' && !$self->is_checkin && !$self->is_renewal);
693 next if ($a->alert_type->event eq 'CHECKOUT' && !$self->is_checkout && !$self->is_renewal);
694 next if (defined $a->alert_type->in_renew && $U->is_true($a->alert_type->in_renew) && !$self->is_renewal);
695 next if (defined $a->alert_type->in_renew && !$U->is_true($a->alert_type->in_renew) && $self->is_renewal);
698 # filter on suppression
699 next if (grep { $a->alert_type->id == $_->alert_type} @$suppressions);
701 # filter on "only at circ lib"
702 if (defined $a->alert_type->at_circ) {
703 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
704 $self->copy->circ_lib->id : $self->copy->circ_lib;
705 my $orgs = $U->get_org_descendants($copy_circ_lib);
707 if ($U->is_true($a->alert_type->invert_location)) {
708 next if (grep {$_ == $self->circ_lib} @$orgs);
710 next unless (grep {$_ == $self->circ_lib} @$orgs);
714 # filter on "only at owning lib"
715 if (defined $a->alert_type->at_owning) {
716 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
717 $self->volume->owning_lib->id : $self->volume->owning_lib;
718 my $orgs = $U->get_org_descendants($copy_owning_lib);
720 if ($U->is_true($a->alert_type->invert_location)) {
721 next if (grep {$_ == $self->circ_lib} @$orgs);
723 next unless (grep {$_ == $self->circ_lib} @$orgs);
727 $a->alert_type->next_status([$U->unique_unnested_numbers($a->alert_type->next_status)]);
729 push @final_alerts, $a;
732 $self->user_copy_alerts(\@final_alerts);
736 sub generate_system_copy_alerts {
738 return unless($self->copy);
740 # don't create system copy alerts if the copy
741 # is in a normal state; we're assuming that there's
742 # never a need to generate a popup for each and every
743 # checkin or checkout of normal items. If this assumption
744 # proves false, then we'll need to add a way to explicitly specify
745 # that a copy alert type should never generate a system copy alert
746 return if $self->copy_state eq 'NORMAL';
748 my $e = $self->editor;
750 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
751 my $suppressions = $e->search_actor_copy_alert_suppress(
752 {org => $suppress_orgs}
755 # events we care about ...
757 push(@$event, 'CHECKIN') if $self->is_checkin;
758 push(@$event, 'CHECKOUT') if $self->is_checkout;
759 return unless scalar(@$event);
761 my $alert_orgs = $U->get_org_ancestors($self->circ_lib);
762 my $alert_types = $e->search_config_copy_alert_type({
764 scope_org => $alert_orgs,
766 state => $self->copy_state,
767 '-or' => [ { in_renew => $self->is_renewal }, { in_renew => undef } ],
771 foreach my $a (@$alert_types) {
772 # filter on "only at circ lib"
773 if (defined $a->at_circ) {
774 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
775 $self->copy->circ_lib->id : $self->copy->circ_lib;
776 my $orgs = $U->get_org_descendants($copy_circ_lib);
778 if ($U->is_true($a->invert_location)) {
779 next if (grep {$_ == $self->circ_lib} @$orgs);
781 next unless (grep {$_ == $self->circ_lib} @$orgs);
785 # filter on "only at owning lib"
786 if (defined $a->at_owning) {
787 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
788 $self->volume->owning_lib->id : $self->volume->owning_lib;
789 my $orgs = $U->get_org_descendants($copy_owning_lib);
791 if ($U->is_true($a->invert_location)) {
792 next if (grep {$_ == $self->circ_lib} @$orgs);
794 next unless (grep {$_ == $self->circ_lib} @$orgs);
798 push @final_types, $a;
802 $logger->info("circulator: found " . scalar(@final_types) . " system alert types for copy" .
808 # keep track of conditions corresponding to suppressed
809 # system alerts, as these may be used to overridee
810 # certain old-style-events
811 my %auto_override_conditions = ();
812 foreach my $t (@final_types) {
813 if ($t->next_status) {
814 if (grep { $t->id == $_->alert_type } @$suppressions) {
817 $t->next_status([$U->unique_unnested_numbers($t->next_status)]);
821 my $alert = new Fieldmapper::asset::copy_alert ();
822 $alert->alert_type($t->id);
823 $alert->copy($self->copy->id);
825 $alert->create_staff($e->requestor->id);
826 $alert->create_time('now');
827 $alert->ack_staff($e->requestor->id);
828 $alert->ack_time('now');
830 $alert = $e->create_asset_copy_alert($alert);
834 $alert->alert_type($t->clone);
836 push(@{$self->next_copy_status}, @{$t->next_status}) if ($t->next_status);
837 if (grep {$_->alert_type == $t->id} @$suppressions) {
838 $auto_override_conditions{join("\t", $t->state, $t->event)} = 1;
840 push(@alerts, $alert) unless (grep {$_->alert_type == $t->id} @$suppressions);
843 $self->system_copy_alerts(\@alerts);
844 $self->overrides_per_copy_alerts(\%auto_override_conditions);
847 sub add_overrides_from_system_copy_alerts {
849 my $e = $self->editor;
851 foreach my $condition (keys %{$self->overrides_per_copy_alerts()}) {
852 if (exists $COPY_ALERT_OVERRIDES{$condition}) {
854 push @{$self->override_args->{events}}, @{ $COPY_ALERT_OVERRIDES{$condition} };
855 # special handling for long-overdue and lost checkouts
856 if (grep { $_ eq 'OPEN_CIRCULATION_EXISTS' } @{ $COPY_ALERT_OVERRIDES{$condition} }) {
857 my $state = (split /\t/, $condition, -1)[0];
859 if ($state eq 'LOST' or $state eq 'LOST_AND_PAID') {
860 $setting = 'circ.copy_alerts.forgive_fines_on_lost_checkin';
861 } elsif ($state eq 'LONGOVERDUE') {
862 $setting = 'circ.copy_alerts.forgive_fines_on_long_overdue_checkin';
866 my $forgive = $U->ou_ancestor_setting_value(
867 $self->circ_lib, $setting, $e
869 if ($U->is_true($forgive)) {
870 $self->void_overdues(1);
872 $self->noop(1); # do not attempt transits, just check it in
881 my $e = $self->editor;
883 $self->next_copy_status([]) unless (defined $self->next_copy_status);
884 $self->overrides_per_copy_alerts({}) unless (defined $self->overrides_per_copy_alerts);
886 # --------------------------------------------------------------------------
887 # Grab the fleshed copy
888 # --------------------------------------------------------------------------
889 unless($self->is_noncat) {
892 $copy = $e->retrieve_asset_copy(
893 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
895 } elsif( $self->copy_barcode ) {
897 $copy = $e->search_asset_copy(
898 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
899 } elsif( $self->reservation ) {
900 my $res = $e->json_query(
902 "select" => {"acp" => ["id"]},
907 "field" => "barcode",
911 "field" => "current_resource"
920 "id" => (ref $self->reservation) ?
921 $self->reservation->id : $self->reservation
926 if (ref $res eq "ARRAY" and scalar @$res) {
927 $logger->info("circulator: mapped reservation " .
928 $self->reservation . " to copy " . $res->[0]->{"id"});
929 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
934 $self->save_trimmed_copy($copy);
939 {from => ['asset.copy_state', $copy->id]}
940 )->[0]{'asset.copy_state'}
943 $self->generate_system_copy_alerts;
944 $self->add_overrides_from_system_copy_alerts;
945 $self->collect_user_copy_alerts;
946 $self->filter_user_copy_alerts;
949 # We can't renew if there is no copy
950 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
951 if $self->is_renewal;
956 # --------------------------------------------------------------------------
958 # --------------------------------------------------------------------------
962 flesh_fields => {au => [ qw/ card / ]}
965 if( $self->patron_id ) {
966 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
967 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
969 } elsif( $self->patron_barcode ) {
971 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
972 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
973 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
975 $patron = $e->retrieve_actor_user($card->usr)
976 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
978 # Use the card we looked up, not the patron's primary, for card active checks
979 $patron->card($card);
982 if( my $copy = $self->copy ) {
985 $flesh->{flesh_fields}->{circ} = ['usr'];
987 my $circ = $e->search_action_circulation([
988 {target_copy => $copy->id, checkin_time => undef}, $flesh
992 $patron = $circ->usr;
993 $circ->usr($patron->id); # de-flesh for consistency
999 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
1000 unless $self->patron($patron) or $self->is_checkin;
1002 unless($self->is_checkin) {
1004 # Check for inactivity and patron reg. expiration
1006 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
1007 unless $U->is_true($patron->active);
1009 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
1010 unless $U->is_true($patron->card->active);
1012 # Expired patrons cannot check out. Renewals for expired
1013 # patrons depend on a setting and will be checked in the
1014 # do_renew subroutine.
1015 if ($self->is_checkout) {
1016 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1017 clean_ISO8601($patron->expire_date));
1019 if (CORE::time > $expire->epoch) {
1020 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1027 # --------------------------------------------------------------------------
1028 # Does the circ permit work
1029 # --------------------------------------------------------------------------
1033 $self->log_me("do_permit()");
1035 unless( $self->editor->requestor->id == $self->patron->id ) {
1036 return $self->bail_on_events($self->editor->event)
1037 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1040 $self->check_captured_holds();
1041 $self->do_copy_checks();
1042 return if $self->bail_out;
1043 $self->run_patron_permit_scripts();
1044 $self->run_copy_permit_scripts()
1045 unless $self->is_precat or $self->is_noncat;
1046 $self->check_item_deposit_events();
1047 $self->override_events();
1048 return if $self->bail_out;
1050 if($self->is_precat and not $self->request_precat) {
1052 OpenILS::Event->new(
1053 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1054 return $self->bail_out(1) unless $self->is_renewal;
1058 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1061 sub check_item_deposit_events {
1063 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
1064 if $self->is_deposit and not $self->is_deposit_exempt;
1065 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
1066 if $self->is_rental and not $self->is_rental_exempt;
1069 # returns true if the user is not required to pay deposits
1070 sub is_deposit_exempt {
1072 my $pid = (ref $self->patron->profile) ?
1073 $self->patron->profile->id : $self->patron->profile;
1074 my $groups = $U->ou_ancestor_setting_value(
1075 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1076 for my $grp (@$groups) {
1077 return 1 if $self->is_group_descendant($grp, $pid);
1082 # returns true if the user is not required to pay rental fees
1083 sub is_rental_exempt {
1085 my $pid = (ref $self->patron->profile) ?
1086 $self->patron->profile->id : $self->patron->profile;
1087 my $groups = $U->ou_ancestor_setting_value(
1088 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1089 for my $grp (@$groups) {
1090 return 1 if $self->is_group_descendant($grp, $pid);
1095 sub is_group_descendant {
1096 my($self, $p_id, $c_id) = @_;
1097 return 0 unless defined $p_id and defined $c_id;
1098 return 1 if $c_id == $p_id;
1099 while(my $grp = $user_groups{$c_id}) {
1100 $c_id = $grp->parent;
1101 return 0 unless defined $c_id;
1102 return 1 if $c_id == $p_id;
1107 sub check_captured_holds {
1109 my $copy = $self->copy;
1110 my $patron = $self->patron;
1112 return undef unless $copy;
1114 my $s = $U->copy_status($copy->status)->id;
1115 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1116 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1118 # Item is on the holds shelf, make sure it's going to the right person
1119 my $hold = $self->editor->search_action_hold_request(
1122 current_copy => $copy->id ,
1123 capture_time => { '!=' => undef },
1124 cancel_time => undef,
1125 fulfillment_time => undef
1129 flesh_fields => { ahr => ['usr'] }
1134 if ($hold and $hold->usr->id == $patron->id) {
1135 $self->checkout_is_for_hold(1);
1139 my $holdau = $hold->usr;
1142 $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1143 $payload->{patron_id} = $holdau->id;
1145 $payload->{patron_name} = "???";
1147 $payload->{hold_id} = $hold->id;
1148 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1149 payload => $payload));
1152 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1157 sub do_copy_checks {
1159 my $copy = $self->copy;
1160 return unless $copy;
1162 my $stat = $U->copy_status($copy->status)->id;
1164 # We cannot check out a copy if it is in-transit
1165 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1166 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1169 $self->handle_claims_returned();
1170 return if $self->bail_out;
1172 # no claims returned circ was found, check if there is any open circ
1173 unless( $self->is_renewal ) {
1175 my $circs = $self->editor->search_action_circulation(
1176 { target_copy => $copy->id, checkin_time => undef }
1179 if(my $old_circ = $circs->[0]) { # an open circ was found
1181 my $payload = {copy => $copy};
1183 if($old_circ->usr == $self->patron->id) {
1185 $payload->{old_circ} = $old_circ;
1187 # If there is an open circulation on the checkout item and an auto-renew
1188 # interval is defined, inform the caller that they should go
1189 # ahead and renew the item instead of warning about open circulations.
1191 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1193 'circ.checkout_auto_renew_age',
1197 if($auto_renew_intvl) {
1198 my $intvl_seconds = OpenILS::Utils::DateTime->interval_to_seconds($auto_renew_intvl);
1199 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( clean_ISO8601($old_circ->xact_start) );
1201 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1202 $payload->{auto_renew} = 1;
1207 return $self->bail_on_events(
1208 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1214 my $LEGACY_CIRC_EVENT_MAP = {
1215 'no_item' => 'ITEM_NOT_CATALOGED',
1216 'actor.usr.barred' => 'PATRON_BARRED',
1217 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1218 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1219 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1220 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1221 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1222 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1223 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1224 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1225 'config.circ_matrix_test.total_copy_hold_ratio' =>
1226 'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1227 'config.circ_matrix_test.available_copy_hold_ratio' =>
1228 'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1232 # ---------------------------------------------------------------------
1233 # This pushes any patron-related events into the list but does not
1234 # set bail_out for any events
1235 # ---------------------------------------------------------------------
1236 sub run_patron_permit_scripts {
1238 my $patronid = $self->patron->id;
1243 my $results = $self->run_indb_circ_test;
1244 unless($self->circ_test_success) {
1245 my @trimmed_results;
1247 if ($self->is_noncat) {
1248 # no_item result is OK during noncat checkout
1249 @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1253 if ($self->checkout_is_for_hold) {
1254 # if this checkout will fulfill a hold, ignore CIRC blocks
1255 # and rely instead on the (later-checked) FULFILL block
1257 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1258 my $fblock_pens = $self->editor->search_config_standing_penalty(
1259 {name => [@pen_names], block_list => {like => '%CIRC%'}});
1261 for my $res (@$results) {
1262 my $name = $res->{fail_part} || '';
1263 next if grep {$_->name eq $name} @$fblock_pens;
1264 push(@trimmed_results, $res);
1268 # not for hold or noncat
1269 @trimmed_results = @$results;
1273 # update the final set of test results
1274 $self->matrix_test_result(\@trimmed_results);
1276 push @allevents, $self->matrix_test_result_events;
1280 $_->{payload} = $self->copy if
1281 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1284 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1286 $self->push_events(@allevents);
1289 sub matrix_test_result_codes {
1291 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1294 sub matrix_test_result_events {
1297 my $event = new OpenILS::Event(
1298 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1300 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1302 } (@{$self->matrix_test_result});
1305 sub run_indb_circ_test {
1307 return $self->matrix_test_result if $self->matrix_test_result;
1309 my $dbfunc = ($self->is_renewal) ?
1310 'action.item_user_renew_test' : 'action.item_user_circ_test';
1312 if( $self->is_precat && $self->request_precat) {
1313 $self->make_precat_copy;
1314 return if $self->bail_out;
1317 my $results = $self->editor->json_query(
1321 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1327 $self->circ_test_success($U->is_true($results->[0]->{success}));
1329 if(my $mp = $results->[0]->{matchpoint}) {
1330 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1331 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1332 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1333 if(defined($results->[0]->{renewals})) {
1334 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1336 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1337 if(defined($results->[0]->{grace_period})) {
1338 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1340 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1341 if(defined($results->[0]->{hard_due_date})) {
1342 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1344 # Grab the *last* response for limit_groups, where it is more likely to be filled
1345 $self->limit_groups($results->[-1]->{limit_groups});
1348 return $self->matrix_test_result($results);
1351 # ---------------------------------------------------------------------
1352 # given a use and copy, this will calculate the circulation policy
1353 # parameters. Only works with in-db circ.
1354 # ---------------------------------------------------------------------
1358 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1360 $self->run_indb_circ_test;
1363 circ_test_success => $self->circ_test_success,
1364 failure_events => [],
1365 failure_codes => [],
1366 matchpoint => $self->circ_matrix_matchpoint
1369 unless($self->circ_test_success) {
1370 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1371 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1374 if($self->circ_matrix_matchpoint) {
1375 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1376 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1377 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1378 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1380 my $policy = $self->get_circ_policy(
1381 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1383 $$results{$_} = $$policy{$_} for keys %$policy;
1389 # ---------------------------------------------------------------------
1390 # Loads the circ policy info for duration, recurring fine, and max
1391 # fine based on the current copy
1392 # ---------------------------------------------------------------------
1393 sub get_circ_policy {
1394 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1397 duration_rule => $duration_rule->name,
1398 recurring_fine_rule => $recurring_fine_rule->name,
1399 max_fine_rule => $max_fine_rule->name,
1400 max_fine => $self->get_max_fine_amount($max_fine_rule),
1401 fine_interval => $recurring_fine_rule->recurrence_interval,
1402 renewal_remaining => $duration_rule->max_renewals,
1403 auto_renewal_remaining => $duration_rule->max_auto_renewals,
1404 grace_period => $recurring_fine_rule->grace_period
1407 if($hard_due_date) {
1408 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1409 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1412 $policy->{duration_date_ceiling} = undef;
1413 $policy->{duration_date_ceiling_force} = undef;
1416 $policy->{duration} = $duration_rule->shrt
1417 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1418 $policy->{duration} = $duration_rule->normal
1419 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1420 $policy->{duration} = $duration_rule->extended
1421 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1423 $policy->{recurring_fine} = $recurring_fine_rule->low
1424 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1425 $policy->{recurring_fine} = $recurring_fine_rule->normal
1426 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1427 $policy->{recurring_fine} = $recurring_fine_rule->high
1428 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1433 sub get_max_fine_amount {
1435 my $max_fine_rule = shift;
1436 my $max_amount = $max_fine_rule->amount;
1438 # if is_percent is true then the max->amount is
1439 # use as a percentage of the copy price
1440 if ($U->is_true($max_fine_rule->is_percent)) {
1441 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1442 $max_amount = $price * $max_fine_rule->amount / 100;
1444 $U->ou_ancestor_setting_value(
1446 'circ.max_fine.cap_at_price',
1450 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1451 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1459 sub run_copy_permit_scripts {
1461 my $copy = $self->copy || return;
1465 my $results = $self->run_indb_circ_test;
1466 push @allevents, $self->matrix_test_result_events
1467 unless $self->circ_test_success;
1469 # See if this copy has an alert message
1470 my $ae = $self->check_copy_alert();
1471 push( @allevents, $ae ) if $ae;
1473 # uniquify the events
1474 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1475 @allevents = values %hash;
1477 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1479 $self->push_events(@allevents);
1483 sub check_copy_alert {
1486 if ($self->new_copy_alerts) {
1488 push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts
1489 if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1491 push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts
1492 if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1495 $self->bail_out(1) if (!$self->override);
1496 return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1500 return undef if $self->is_renewal;
1501 return OpenILS::Event->new(
1502 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1503 if $self->copy and $self->copy->alert_message;
1509 # --------------------------------------------------------------------------
1510 # If the call is overriding and has permissions to override every collected
1511 # event, the are cleared. Any event that the caller does not have
1512 # permission to override, will be left in the event list and bail_out will
1514 # XXX We need code in here to cancel any holds/transits on copies
1515 # that are being force-checked out
1516 # --------------------------------------------------------------------------
1517 sub override_events {
1519 my @events = @{$self->events};
1520 return unless @events;
1521 my $oargs = $self->override_args;
1523 if(!$self->override) {
1524 return $self->bail_out(1)
1525 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1530 for my $e (@events) {
1531 my $tc = $e->{textcode};
1532 next if $tc eq 'SUCCESS';
1533 if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1534 my $ov = "$tc.override";
1535 $logger->info("circulator: attempting to override event: $ov");
1537 return $self->bail_on_events($self->editor->event)
1538 unless( $self->editor->allowed($ov) );
1540 return $self->bail_out(1);
1546 # --------------------------------------------------------------------------
1547 # If there is an open claimsreturn circ on the requested copy, close the
1548 # circ if overriding, otherwise bail out
1549 # --------------------------------------------------------------------------
1550 sub handle_claims_returned {
1552 my $copy = $self->copy;
1554 my $CR = $self->editor->search_action_circulation(
1556 target_copy => $copy->id,
1557 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1558 checkin_time => undef,
1562 return unless ($CR = $CR->[0]);
1566 # - If the caller has set the override flag, we will check the item in
1567 if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1569 $CR->checkin_time('now');
1570 $CR->checkin_scan_time('now');
1571 $CR->checkin_lib($self->circ_lib);
1572 $CR->checkin_workstation($self->editor->requestor->wsid);
1573 $CR->checkin_staff($self->editor->requestor->id);
1575 $evt = $self->editor->event
1576 unless $self->editor->update_action_circulation($CR);
1579 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1582 $self->bail_on_events($evt) if $evt;
1587 # --------------------------------------------------------------------------
1588 # This performs the checkout
1589 # --------------------------------------------------------------------------
1593 $self->log_me("do_checkout()");
1595 # make sure perms are good if this isn't a renewal
1596 unless( $self->is_renewal ) {
1597 return $self->bail_on_events($self->editor->event)
1598 unless( $self->editor->allowed('COPY_CHECKOUT') );
1601 # verify the permit key
1602 unless( $self->check_permit_key ) {
1603 if( $self->permit_override ) {
1604 return $self->bail_on_events($self->editor->event)
1605 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1607 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1611 # if this is a non-cataloged circ, build the circ and finish
1612 if( $self->is_noncat ) {
1613 $self->checkout_noncat;
1615 OpenILS::Event->new('SUCCESS',
1616 payload => { noncat_circ => $self->circ }));
1620 if( $self->is_precat ) {
1621 $self->make_precat_copy;
1622 return if $self->bail_out;
1624 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1625 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1628 $self->do_copy_checks;
1629 return if $self->bail_out;
1631 $self->run_checkout_scripts();
1632 return if $self->bail_out;
1634 $self->build_checkout_circ_object();
1635 return if $self->bail_out;
1637 my $modify_to_start = $self->booking_adjusted_due_date();
1638 return if $self->bail_out;
1640 $self->apply_modified_due_date($modify_to_start);
1641 return if $self->bail_out;
1643 return $self->bail_on_events($self->editor->event)
1644 unless $self->editor->create_action_circulation($self->circ);
1646 # refresh the circ to force local time zone for now
1647 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1649 if($self->limit_groups) {
1650 $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1653 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1655 return if $self->bail_out;
1657 $self->apply_deposit_fee();
1658 return if $self->bail_out;
1660 $self->handle_checkout_holds();
1661 return if $self->bail_out;
1663 # ------------------------------------------------------------------------------
1664 # Update the patron penalty info in the DB. Run it for permit-overrides
1665 # since the penalties are not updated during the permit phase
1666 # ------------------------------------------------------------------------------
1667 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1669 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1672 if($self->is_renewal) {
1673 # flesh the billing summary for the checked-in circ
1674 $pcirc = $self->editor->retrieve_action_circulation([
1676 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1681 OpenILS::Event->new('SUCCESS',
1683 copy => $U->unflesh_copy($self->copy),
1684 volume => $self->volume,
1685 circ => $self->circ,
1687 holds_fulfilled => $self->fulfilled_holds,
1688 deposit_billing => $self->deposit_billing,
1689 rental_billing => $self->rental_billing,
1690 parent_circ => $pcirc,
1691 patron => ($self->return_patron) ? $self->patron : undef,
1692 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1698 sub apply_deposit_fee {
1700 my $copy = $self->copy;
1702 ($self->is_deposit and not $self->is_deposit_exempt) or
1703 ($self->is_rental and not $self->is_rental_exempt);
1705 return if $self->is_deposit and $self->skip_deposit_fee;
1706 return if $self->is_rental and $self->skip_rental_fee;
1708 my $bill = Fieldmapper::money::billing->new;
1709 my $amount = $copy->deposit_amount;
1713 if($self->is_deposit) {
1714 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1716 $self->deposit_billing($bill);
1718 $billing_type = OILS_BILLING_TYPE_RENTAL;
1720 $self->rental_billing($bill);
1723 $bill->xact($self->circ->id);
1724 $bill->amount($amount);
1725 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1726 $bill->billing_type($billing_type);
1727 $bill->btype($btype);
1728 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1730 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1735 my $copy = $self->copy;
1737 my $stat = $copy->status if ref $copy->status;
1738 my $loc = $copy->location if ref $copy->location;
1739 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1741 $copy->status($stat->id) if $stat;
1742 $copy->location($loc->id) if $loc;
1743 $copy->circ_lib($circ_lib->id) if $circ_lib;
1744 $copy->editor($self->editor->requestor->id);
1745 $copy->edit_date('now');
1746 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1748 return $self->bail_on_events($self->editor->event)
1749 unless $self->editor->update_asset_copy($self->copy);
1751 $copy->status($U->copy_status($copy->status));
1752 $copy->location($loc) if $loc;
1753 $copy->circ_lib($circ_lib) if $circ_lib;
1756 sub update_reservation {
1758 my $reservation = $self->reservation;
1760 my $usr = $reservation->usr;
1761 my $target_rt = $reservation->target_resource_type;
1762 my $target_r = $reservation->target_resource;
1763 my $current_r = $reservation->current_resource;
1765 $reservation->usr($usr->id) if ref $usr;
1766 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1767 $reservation->target_resource($target_r->id) if ref $target_r;
1768 $reservation->current_resource($current_r->id) if ref $current_r;
1770 return $self->bail_on_events($self->editor->event)
1771 unless $self->editor->update_booking_reservation($self->reservation);
1774 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1775 $self->reservation($reservation);
1779 sub bail_on_events {
1780 my( $self, @evts ) = @_;
1781 $self->push_events(@evts);
1785 # ------------------------------------------------------------------------------
1786 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1787 # affects copies that will fulfill holds and CIRC affects all other copies.
1788 # If blocks exists, bail, push Events onto the event pile, and return true.
1789 # ------------------------------------------------------------------------------
1790 sub check_hold_fulfill_blocks {
1793 # With the addition of ignore_proximity in csp, we need to fetch
1794 # the proximity of both the circ_lib and the copy's circ_lib to
1795 # the patron's home_ou.
1796 my ($ou_prox, $copy_prox);
1797 my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1798 $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1799 $ou_prox = -1 unless (defined($ou_prox));
1800 my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1801 if ($copy_ou == $self->circ_lib) {
1802 # Save us the time of an extra query.
1803 $copy_prox = $ou_prox;
1805 $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1806 $copy_prox = -1 unless (defined($copy_prox));
1809 # See if the user has any penalties applied that prevent hold fulfillment
1810 my $pens = $self->editor->json_query({
1811 select => {csp => ['name', 'label']},
1812 from => {ausp => {csp => {}}},
1815 usr => $self->patron->id,
1816 org_unit => $U->get_org_full_path($self->circ_lib),
1818 {stop_date => undef},
1819 {stop_date => {'>' => 'now'}}
1823 block_list => {'like' => '%FULFILL%'},
1825 {ignore_proximity => undef},
1826 {ignore_proximity => {'<' => $ou_prox}},
1827 {ignore_proximity => {'<' => $copy_prox}}
1833 return 0 unless @$pens;
1835 for my $pen (@$pens) {
1836 $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1837 my $event = OpenILS::Event->new($pen->{name});
1838 $event->{desc} = $pen->{label};
1839 $self->push_events($event);
1842 $self->override_events;
1843 return $self->bail_out;
1847 # ------------------------------------------------------------------------------
1848 # When an item is checked out, see if we can fulfill a hold for this patron
1849 # ------------------------------------------------------------------------------
1850 sub handle_checkout_holds {
1852 my $copy = $self->copy;
1853 my $patron = $self->patron;
1855 my $e = $self->editor;
1856 $self->fulfilled_holds([]);
1858 # non-cats can't fulfill a hold
1859 return if $self->is_noncat;
1861 my $hold = $e->search_action_hold_request({
1862 current_copy => $copy->id ,
1863 cancel_time => undef,
1864 fulfillment_time => undef
1867 if($hold and $hold->usr != $patron->id) {
1868 # reset the hold since the copy is now checked out
1870 $logger->info("circulator: un-targeting hold ".$hold->id.
1871 " because copy ".$copy->id." is getting checked out");
1873 $hold->clear_prev_check_time;
1874 $hold->clear_current_copy;
1875 $hold->clear_capture_time;
1876 $hold->clear_shelf_time;
1877 $hold->clear_shelf_expire_time;
1878 $hold->clear_current_shelf_lib;
1880 return $self->bail_on_event($e->event)
1881 unless $e->update_action_hold_request($hold);
1887 $hold = $self->find_related_user_hold($copy, $patron) or return;
1888 $logger->info("circulator: found related hold to fulfill in checkout");
1891 return if $self->check_hold_fulfill_blocks;
1893 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1895 # if the hold was never officially captured, capture it.
1896 $hold->current_copy($copy->id);
1897 $hold->capture_time('now') unless $hold->capture_time;
1898 $hold->fulfillment_time('now');
1899 $hold->fulfillment_staff($e->requestor->id);
1900 $hold->fulfillment_lib($self->circ_lib);
1902 return $self->bail_on_events($e->event)
1903 unless $e->update_action_hold_request($hold);
1905 return $self->fulfilled_holds([$hold->id]);
1909 # ------------------------------------------------------------------------------
1910 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1911 # the patron directly targets the checked out item, see if there is another hold
1912 # for the patron that could be fulfilled by the checked out item. Fulfill the
1913 # oldest hold and only fulfill 1 of them.
1915 # For "another hold":
1917 # First, check for one that the copy matches via hold_copy_map, ensuring that
1918 # *any* hold type that this copy could fill may end up filled.
1920 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1921 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1922 # that are non-requestable to count as capturing those hold types.
1923 # ------------------------------------------------------------------------------
1924 sub find_related_user_hold {
1925 my($self, $copy, $patron) = @_;
1926 my $e = $self->editor;
1928 # holds on precat copies are always copy-level, so this call will
1929 # always return undef. Exit early.
1930 return undef if $self->is_precat;
1932 return undef unless $U->ou_ancestor_setting_value(
1933 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1935 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1937 select => {ahr => ['id']},
1946 fkey => 'current_copy',
1947 type => 'left' # there may be no current_copy
1954 fulfillment_time => undef,
1955 cancel_time => undef,
1957 {expire_time => undef},
1958 {expire_time => {'>' => 'now'}}
1962 target_copy => $self->copy->id
1966 {id => undef}, # left-join copy may be nonexistent
1967 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1971 order_by => {ahr => {request_time => {direction => 'asc'}}},
1975 my $hold_info = $e->json_query($args)->[0];
1976 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1977 return undef if $U->ou_ancestor_setting_value(
1978 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1980 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1982 select => {ahr => ['id']},
1987 fkey => 'current_copy',
1988 type => 'left' # there may be no current_copy
1995 fulfillment_time => undef,
1996 cancel_time => undef,
1998 {expire_time => undef},
1999 {expire_time => {'>' => 'now'}}
2006 target => $self->volume->id
2012 target => $self->title->id
2018 {id => undef}, # left-join copy may be nonexistent
2019 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2023 order_by => {ahr => {request_time => {direction => 'asc'}}},
2027 $hold_info = $e->json_query($args)->[0];
2028 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2033 sub run_checkout_scripts {
2046 my $hard_due_date_name;
2048 $self->run_indb_circ_test();
2049 $duration = $self->circ_matrix_matchpoint->duration_rule;
2050 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2051 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2052 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2054 $duration_name = $duration->name if $duration;
2055 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2058 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2059 return $self->bail_on_events($evt) if ($evt && !$nobail);
2061 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2062 return $self->bail_on_events($evt) if ($evt && !$nobail);
2064 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2065 return $self->bail_on_events($evt) if ($evt && !$nobail);
2067 if($hard_due_date_name) {
2068 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2069 return $self->bail_on_events($evt) if ($evt && !$nobail);
2075 # The item circulates with an unlimited duration
2079 $hard_due_date = undef;
2082 $self->duration_rule($duration);
2083 $self->recurring_fines_rule($recurring);
2084 $self->max_fine_rule($max_fine);
2085 $self->hard_due_date($hard_due_date);
2089 sub build_checkout_circ_object {
2092 my $circ = Fieldmapper::action::circulation->new;
2093 my $duration = $self->duration_rule;
2094 my $max = $self->max_fine_rule;
2095 my $recurring = $self->recurring_fines_rule;
2096 my $hard_due_date = $self->hard_due_date;
2097 my $copy = $self->copy;
2098 my $patron = $self->patron;
2099 my $duration_date_ceiling;
2100 my $duration_date_ceiling_force;
2104 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2105 $duration_date_ceiling = $policy->{duration_date_ceiling};
2106 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2108 my $dname = $duration->name;
2109 my $mname = $max->name;
2110 my $rname = $recurring->name;
2112 if($hard_due_date) {
2113 $hdname = $hard_due_date->name;
2116 $logger->debug("circulator: building circulation ".
2117 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2119 $circ->duration($policy->{duration});
2120 $circ->recurring_fine($policy->{recurring_fine});
2121 $circ->duration_rule($duration->name);
2122 $circ->recurring_fine_rule($recurring->name);
2123 $circ->max_fine_rule($max->name);
2124 $circ->max_fine($policy->{max_fine});
2125 $circ->fine_interval($recurring->recurrence_interval);
2126 $circ->renewal_remaining($duration->max_renewals);
2127 $circ->auto_renewal_remaining($duration->max_auto_renewals);
2128 $circ->grace_period($policy->{grace_period});
2132 $logger->info("circulator: copy found with an unlimited circ duration");
2133 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2134 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2135 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2136 $circ->renewal_remaining(0);
2137 $circ->grace_period(0);
2140 $circ->target_copy( $copy->id );
2141 $circ->usr( $patron->id );
2142 $circ->circ_lib( $self->circ_lib );
2143 $circ->workstation($self->editor->requestor->wsid)
2144 if defined $self->editor->requestor->wsid;
2146 # renewals maintain a link to the parent circulation
2147 $circ->parent_circ($self->parent_circ);
2149 if( $self->is_renewal ) {
2150 $circ->opac_renewal('t') if $self->opac_renewal;
2151 $circ->phone_renewal('t') if $self->phone_renewal;
2152 $circ->desk_renewal('t') if $self->desk_renewal;
2153 $circ->auto_renewal('t') if $self->auto_renewal;
2154 $circ->renewal_remaining($self->renewal_remaining);
2155 $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2156 $circ->circ_staff($self->editor->requestor->id);
2159 # if the user provided an overiding checkout time,
2160 # (e.g. the checkout really happened several hours ago), then
2161 # we apply that here. Does this need a perm??
2162 $circ->xact_start(clean_ISO8601($self->checkout_time))
2163 if $self->checkout_time;
2165 # if a patron is renewing, 'requestor' will be the patron
2166 $circ->circ_staff($self->editor->requestor->id);
2167 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2172 sub do_reservation_pickup {
2175 $self->log_me("do_reservation_pickup()");
2177 $self->reservation->pickup_time('now');
2180 $self->reservation->current_resource &&
2181 $U->is_true($self->reservation->target_resource_type->catalog_item)
2183 # We used to try to set $self->copy and $self->patron here,
2184 # but that should already be done.
2186 $self->run_checkout_scripts(1);
2188 my $duration = $self->duration_rule;
2189 my $max = $self->max_fine_rule;
2190 my $recurring = $self->recurring_fines_rule;
2192 if ($duration && $max && $recurring) {
2193 my $policy = $self->get_circ_policy($duration, $recurring, $max);
2195 my $dname = $duration->name;
2196 my $mname = $max->name;
2197 my $rname = $recurring->name;
2199 $logger->debug("circulator: updating reservation ".
2200 "with duration=$dname, maxfine=$mname, recurring=$rname");
2202 $self->reservation->fine_amount($policy->{recurring_fine});
2203 $self->reservation->max_fine($policy->{max_fine});
2204 $self->reservation->fine_interval($recurring->recurrence_interval);
2207 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2208 $self->update_copy();
2211 $self->reservation->fine_amount(
2212 $self->reservation->target_resource_type->fine_amount
2214 $self->reservation->max_fine(
2215 $self->reservation->target_resource_type->max_fine
2217 $self->reservation->fine_interval(
2218 $self->reservation->target_resource_type->fine_interval
2222 $self->update_reservation();
2225 sub do_reservation_return {
2227 my $request = shift;
2229 $self->log_me("do_reservation_return()");
2231 if (not ref $self->reservation) {
2232 my ($reservation, $evt) =
2233 $U->fetch_booking_reservation($self->reservation);
2234 return $self->bail_on_events($evt) if $evt;
2235 $self->reservation($reservation);
2238 $self->handle_fines(1);
2239 $self->reservation->return_time('now');
2240 $self->update_reservation();
2241 $self->reshelve_copy if $self->copy;
2243 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2244 $self->copy( $self->reservation->current_resource->catalog_item );
2248 sub booking_adjusted_due_date {
2250 my $circ = $self->circ;
2251 my $copy = $self->copy;
2253 return undef unless $self->use_booking;
2257 if( $self->due_date ) {
2259 return $self->bail_on_events($self->editor->event)
2260 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2262 $circ->due_date(clean_ISO8601($self->due_date));
2266 return unless $copy and $circ->due_date;
2269 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2270 if (@$booking_items) {
2271 my $booking_item = $booking_items->[0];
2272 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2274 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2275 my $shorten_circ_setting = $resource_type->elbow_room ||
2276 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2279 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2280 my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2281 resource => $booking_item->id
2282 , search_start => 'now'
2283 , search_end => $circ->due_date
2284 , fields => { cancel_time => undef, return_time => undef }
2286 $booking_ses->disconnect;
2288 throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2289 return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2291 my $dt_parser = DateTime::Format::ISO8601->new;
2292 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($circ->due_date) );
2294 for my $bid (@$bookings) {
2296 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2298 my $booking_start = $dt_parser->parse_datetime( clean_ISO8601($booking->start_time) );
2299 my $booking_end = $dt_parser->parse_datetime( clean_ISO8601($booking->end_time) );
2301 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2302 if ($booking_start < DateTime->now);
2305 if ($U->is_true($stop_circ_setting)) {
2306 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2308 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2309 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2312 # We set the circ duration here only to affect the logic that will
2313 # later (in a DB trigger) mangle the time part of the due date to
2314 # 11:59pm. Having any circ duration that is not a whole number of
2315 # days is enough to prevent the "correction."
2316 my $new_circ_duration = $due_date->epoch - time;
2317 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2318 $circ->duration("$new_circ_duration seconds");
2320 $circ->due_date(clean_ISO8601($due_date->strftime('%FT%T%z')));
2324 return $self->bail_on_events($self->editor->event)
2325 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2331 sub apply_modified_due_date {
2333 my $shift_earlier = shift;
2334 my $circ = $self->circ;
2335 my $copy = $self->copy;
2337 if( $self->due_date ) {
2339 return $self->bail_on_events($self->editor->event)
2340 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2342 $circ->due_date(clean_ISO8601($self->due_date));
2346 # if the due_date lands on a day when the location is closed
2347 return unless $copy and $circ->due_date;
2349 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2351 # due-date overlap should be determined by the location the item
2352 # is checked out from, not the owning or circ lib of the item
2353 my $org = $self->circ_lib;
2355 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2356 " with an item due date of ".$circ->due_date );
2358 my $dateinfo = $U->storagereq(
2359 'open-ils.storage.actor.org_unit.closed_date.overlap',
2360 $org, $circ->due_date );
2363 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2364 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2366 # XXX make the behavior more dynamic
2367 # for now, we just push the due date to after the close date
2368 if ($shift_earlier) {
2369 $circ->due_date($dateinfo->{start});
2371 $circ->due_date($dateinfo->{end});
2379 sub create_due_date {
2380 my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2382 # Look up circulating library's TZ, or else use client TZ, falling
2384 my $tz = $U->ou_ancestor_setting_value(
2390 my $due_date = $start_time ?
2391 DateTime::Format::ISO8601
2393 ->parse_datetime(clean_ISO8601($start_time))
2394 ->set_time_zone($tz) :
2395 DateTime->now(time_zone => $tz);
2397 # add the circ duration
2398 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($duration, $due_date));
2401 my $cdate = DateTime::Format::ISO8601
2403 ->parse_datetime(clean_ISO8601($date_ceiling))
2404 ->set_time_zone($tz);
2406 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2407 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2412 # return ISO8601 time with timezone
2413 return $due_date->strftime('%FT%T%z');
2418 sub make_precat_copy {
2420 my $copy = $self->copy;
2421 return $self->bail_on_events(OpenILS::Event->new('PERM_FAILURE'))
2422 unless $self->editor->allowed('CREATE_PRECAT') || $self->is_renewal;
2425 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2427 $copy->editor($self->editor->requestor->id);
2428 $copy->edit_date('now');
2429 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2430 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2431 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2432 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2433 $self->update_copy();
2437 $logger->info("circulator: Creating a new precataloged ".
2438 "copy in checkout with barcode " . $self->copy_barcode);
2440 $copy = Fieldmapper::asset::copy->new;
2441 $copy->circ_lib($self->circ_lib);
2442 $copy->creator($self->editor->requestor->id);
2443 $copy->editor($self->editor->requestor->id);
2444 $copy->barcode($self->copy_barcode);
2445 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2446 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2447 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2449 $copy->dummy_title($self->dummy_title || "");
2450 $copy->dummy_author($self->dummy_author || "");
2451 $copy->dummy_isbn($self->dummy_isbn || "");
2452 $copy->circ_modifier($self->circ_modifier);
2455 # See if we need to override the circ_lib for the copy with a configured circ_lib
2456 # Setting is shortname of the org unit
2457 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2458 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2460 if($precat_circ_lib) {
2461 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2464 $self->bail_on_events($self->editor->event);
2468 $copy->circ_lib($org->id);
2472 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2474 $self->push_events($self->editor->event);
2480 sub checkout_noncat {
2486 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2487 my $count = $self->noncat_count || 1;
2488 my $cotime = clean_ISO8601($self->checkout_time) || "";
2490 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2494 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2495 $self->editor->requestor->id,
2503 $self->push_events($evt);
2511 # if an item is in transit but the status doesn't agree, then we need to fix things.
2512 # The next two subs will hopefully do that
2513 sub fix_broken_transit_status {
2516 # Capture the transit so we don't have to fetch it again later during checkin
2517 # This used to live in sub check_transit_checkin_interval and later again in
2520 $self->editor->search_action_transit_copy(
2521 {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2525 if ($self->transit && $U->copy_status($self->copy->status)->id != OILS_COPY_STATUS_IN_TRANSIT) {
2526 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2527 " that is in-transit but without the In Transit status... fixing");
2528 $self->copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2529 # FIXME - do we want to make this permanent if the checkin bails?
2534 sub cancel_transit_if_circ_exists {
2536 if ($self->circ && $self->transit) {
2537 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2538 " that is in-transit AND circulating... aborting the transit");
2539 my $circ_ses = create OpenSRF::AppSession("open-ils.circ");
2540 my $result = $circ_ses->request(
2541 "open-ils.circ.transit.abort",
2542 $self->editor->authtoken,
2543 { 'transitid' => $self->transit->id }
2545 $logger->warn("circulator: transit abort result: ".$result);
2546 $circ_ses->disconnect;
2547 $self->transit(undef);
2551 # If a copy goes into transit and is then checked in before the transit checkin
2552 # interval has expired, push an event onto the overridable events list.
2553 sub check_transit_checkin_interval {
2556 # only concerned with in-transit items
2557 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2559 # no interval, no problem
2560 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2561 return unless $interval;
2563 # transit from X to X for whatever reason has no min interval
2564 return if $self->transit->source == $self->transit->dest;
2566 my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2567 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2568 my $horizon = $t_start->add(seconds => $seconds);
2570 # See if we are still within the transit checkin forbidden range
2571 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2572 if $horizon > DateTime->now;
2575 # Retarget local holds at checkin
2576 sub checkin_retarget {
2578 return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2579 return unless $self->is_checkin; # Renewals need not be checked
2580 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2581 return if $self->is_precat; # No holds for precats
2582 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2583 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2584 my $status = $U->copy_status($self->copy->status);
2585 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2586 # Specifically target items that are likely new (by status ID)
2587 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2588 my $location = $self->copy->location;
2589 if(!ref($location)) {
2590 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2591 $self->copy->location($location);
2593 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2595 # Fetch holds for the bib
2596 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2597 $self->editor->authtoken,
2600 capture_time => undef, # No touching captured holds
2601 frozen => 'f', # Don't bother with frozen holds
2602 pickup_lib => $self->circ_lib # Only holds actually here
2605 # Error? Skip the step.
2606 return if exists $result->{"ilsevent"};
2610 foreach my $holdlist (keys %{$result}) {
2611 push @$holds, @{$result->{$holdlist}};
2614 return if scalar(@$holds) == 0; # No holds, no retargeting
2616 # Check for parts on this copy
2617 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2618 my %parts_hash = ();
2619 %parts_hash = map {$_->part, 1} @$parts if @$parts;
2621 # Loop over holds in request-ish order
2622 # Stage 1: Get them into request-ish order
2623 # Also grab type and target for skipping low hanging ones
2624 $result = $self->editor->json_query({
2625 "select" => { "ahr" => ["id", "hold_type", "target"] },
2626 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2627 "where" => { "id" => $holds },
2629 { "class" => "pgt", "field" => "hold_priority"},
2630 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2631 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2632 { "class" => "ahr", "field" => "request_time"}
2637 if (ref $result eq "ARRAY" and scalar @$result) {
2638 foreach (@{$result}) {
2639 # Copy level, but not this copy?
2640 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2641 and $_->{target} != $self->copy->id);
2642 # Volume level, but not this volume?
2643 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2644 if(@$parts) { # We have parts?
2646 next if ($_->{hold_type} eq 'T');
2647 # Skip part holds for parts not on this copy
2648 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2650 # No parts, no part holds
2651 next if ($_->{hold_type} eq 'P');
2653 # So much for easy stuff, attempt a retarget!
2654 my $tresult = $U->simplereq(
2655 'open-ils.hold-targeter',
2656 'open-ils.hold-targeter.target',
2657 {hold => $_->{id}, find_copy => $self->copy->id}
2659 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2660 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2668 $self->log_me("do_checkin()");
2670 return $self->bail_on_events(
2671 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2674 $self->fix_broken_transit_status; # if applicable
2675 $self->check_transit_checkin_interval;
2676 $self->checkin_retarget;
2678 # the renew code and mk_env should have already found our circulation object
2679 unless( $self->circ ) {
2681 my $circs = $self->editor->search_action_circulation(
2682 { target_copy => $self->copy->id, checkin_time => undef });
2684 $self->circ($$circs[0]);
2686 # for now, just warn if there are multiple open circs on a copy
2687 $logger->warn("circulator: we have ".scalar(@$circs).
2688 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2690 $self->cancel_transit_if_circ_exists; # if applicable
2692 my $stat = $U->copy_status($self->copy->status)->id;
2694 # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2695 # differently if they are already paid for. We need to check for this
2696 # early since overdue generation is potentially affected.
2697 my $dont_change_lost_zero = 0;
2698 if ($stat == OILS_COPY_STATUS_LOST
2699 || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2700 || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2702 # LOST fine settings are controlled by the copy's circ lib, not the the
2704 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2705 $self->copy->circ_lib->id : $self->copy->circ_lib;
2706 $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2707 $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2708 $self->editor) || 0;
2710 # Don't assume there's always a circ based on copy status
2711 if ($dont_change_lost_zero && $self->circ) {
2712 my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2713 $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2716 $self->dont_change_lost_zero($dont_change_lost_zero);
2719 my $latest_inventory = Fieldmapper::asset::latest_inventory->new;
2721 if ($self->do_inventory_update) {
2722 $latest_inventory->inventory_date('now');
2723 $latest_inventory->inventory_workstation($self->editor->requestor->wsid);
2724 $latest_inventory->copy($self->copy->id());
2726 my $alci = $self->editor->search_asset_latest_inventory(
2727 {copy => $self->copy->id}
2729 $latest_inventory = $alci->[0]
2731 $self->latest_inventory($latest_inventory);
2733 if( $self->checkin_check_holds_shelf() ) {
2734 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2735 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2736 if($self->fake_hold_dest) {
2737 $self->hold->pickup_lib($self->circ_lib);
2739 $self->checkin_flesh_events;
2743 unless( $self->is_renewal ) {
2744 return $self->bail_on_events($self->editor->event)
2745 unless $self->editor->allowed('COPY_CHECKIN');
2748 $self->push_events($self->check_copy_alert());
2749 $self->push_events($self->check_checkin_copy_status());
2751 # if the circ is marked as 'claims returned', add the event to the list
2752 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2753 if ($self->circ and $self->circ->stop_fines
2754 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2756 $self->check_circ_deposit();
2758 # handle the overridable events
2759 $self->override_events unless $self->is_renewal;
2760 return if $self->bail_out;
2763 $self->checkin_handle_circ_start;
2764 return if $self->bail_out;
2766 if (!$dont_change_lost_zero) {
2767 # if this circ is LOST and we are configured to generate overdue
2768 # fines for lost items on checkin (to fill the gap between mark
2769 # lost time and when the fines would have naturally stopped), then
2770 # stop_fines is no longer valid and should be cleared.
2772 # stop_fines will be set again during the handle_fines() stage.
2773 # XXX should this setting come from the copy circ lib (like other
2774 # LOST settings), instead of the circulation circ lib?
2775 if ($stat == OILS_COPY_STATUS_LOST) {
2776 $self->circ->clear_stop_fines if
2777 $U->ou_ancestor_setting_value(
2779 OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2784 # Set stop_fines when claimed never checked out
2785 $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2787 # handle fines for this circ, including overdue gen if needed
2788 $self->handle_fines;
2791 # Void any item deposits if the library wants to
2792 $self->check_circ_deposit(1);
2794 $self->checkin_handle_circ_finish;
2795 return if $self->bail_out;
2796 $self->checkin_changed(1);
2798 } elsif( $self->transit ) {
2799 my $hold_transit = $self->process_received_transit;
2800 $self->checkin_changed(1);
2802 if( $self->bail_out ) {
2803 $self->checkin_flesh_events;
2807 if( my $e = $self->check_checkin_copy_status() ) {
2808 # If the original copy status is special, alert the caller
2809 my $ev = $self->events;
2810 $self->events([$e]);
2811 $self->override_events;
2812 return if $self->bail_out;
2816 if( $hold_transit or
2817 $U->copy_status($self->copy->status)->id
2818 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2821 if( $hold_transit ) {
2822 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2824 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2829 if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2831 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2832 $self->reshelve_copy(1);
2833 $self->cancelled_hold_transit(1);
2834 $self->notify_hold(0); # don't notify for cancelled holds
2835 $self->fake_hold_dest(0);
2836 return if $self->bail_out;
2838 } elsif ($hold and $hold->hold_type eq 'R') {
2840 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2841 $self->notify_hold(0); # No need to notify
2842 $self->fake_hold_dest(0);
2843 $self->noop(1); # Don't try and capture for other holds/transits now
2844 $self->update_copy();
2845 $hold->fulfillment_time('now');
2846 $self->bail_on_events($self->editor->event)
2847 unless $self->editor->update_action_hold_request($hold);
2851 # hold transited to correct location
2852 if($self->fake_hold_dest) {
2853 $hold->pickup_lib($self->circ_lib);
2855 $self->checkin_flesh_events;
2860 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2862 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2863 " that is in-transit, but there is no transit.. repairing");
2864 $self->reshelve_copy(1);
2865 return if $self->bail_out;
2868 if( $self->is_renewal ) {
2869 $self->finish_fines_and_voiding;
2870 return if $self->bail_out;
2871 $self->push_events(OpenILS::Event->new('SUCCESS'));
2875 # ------------------------------------------------------------------------------
2876 # Circulations and transits are now closed where necessary. Now go on to see if
2877 # this copy can fulfill a hold or needs to be routed to a different location
2878 # ------------------------------------------------------------------------------
2880 my $needed_for_something = 0; # formerly "needed_for_hold"
2882 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2884 if (!$self->remote_hold) {
2885 if ($self->use_booking) {
2886 my $potential_hold = $self->hold_capture_is_possible;
2887 my $potential_reservation = $self->reservation_capture_is_possible;
2889 if ($potential_hold and $potential_reservation) {
2890 $logger->info("circulator: item could fulfill either hold or reservation");
2891 $self->push_events(new OpenILS::Event(
2892 "HOLD_RESERVATION_CONFLICT",
2893 "hold" => $potential_hold,
2894 "reservation" => $potential_reservation
2896 return if $self->bail_out;
2897 } elsif ($potential_hold) {
2898 $needed_for_something =
2899 $self->attempt_checkin_hold_capture;
2900 } elsif ($potential_reservation) {
2901 $needed_for_something =
2902 $self->attempt_checkin_reservation_capture;
2905 $needed_for_something = $self->attempt_checkin_hold_capture;
2908 return if $self->bail_out;
2910 unless($needed_for_something) {
2911 my $circ_lib = (ref $self->copy->circ_lib) ?
2912 $self->copy->circ_lib->id : $self->copy->circ_lib;
2914 if( $self->remote_hold ) {
2915 $circ_lib = $self->remote_hold->pickup_lib;
2916 $logger->warn("circulator: Copy ".$self->copy->barcode.
2917 " is on a remote hold's shelf, sending to $circ_lib");
2920 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2922 my $suppress_transit = 0;
2924 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2925 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2926 if($suppress_transit_source && $suppress_transit_source->{value}) {
2927 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2928 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2929 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2930 $suppress_transit = 1;
2935 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2936 # copy is where it needs to be, either for hold or reshelving
2938 $self->checkin_handle_precat();
2939 return if $self->bail_out;
2942 # copy needs to transit "home", or stick here if it's a floating copy
2944 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2945 my $res = $self->editor->json_query(
2947 'evergreen.can_float',
2948 $self->copy->floating->id,
2949 $self->copy->circ_lib,
2954 $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res;
2956 if ($can_float) { # Yep, floating, stick here
2957 $self->checkin_changed(1);
2958 $self->copy->circ_lib( $self->circ_lib );
2961 my $bc = $self->copy->barcode;
2962 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2963 $self->checkin_build_copy_transit($circ_lib);
2964 return if $self->bail_out;
2965 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2969 } else { # no-op checkin
2970 if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2971 my $res = $self->editor->json_query(
2974 'evergreen.can_float',
2975 $self->copy->floating->id,
2976 $self->copy->circ_lib,
2981 if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2982 $self->checkin_changed(1);
2983 $self->copy->circ_lib( $self->circ_lib );
2989 if($self->claims_never_checked_out and
2990 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2992 # the item was not supposed to be checked out to the user and should now be marked as missing
2993 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
2994 $self->copy->status($next_status);
2998 $self->reshelve_copy unless $needed_for_something;
3001 return if $self->bail_out;
3003 unless($self->checkin_changed) {
3005 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
3006 my $stat = $U->copy_status($self->copy->status)->id;
3008 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
3009 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
3010 $self->bail_out(1); # no need to commit anything
3014 $self->push_events(OpenILS::Event->new('SUCCESS'))
3015 unless @{$self->events};
3018 $self->finish_fines_and_voiding;
3020 OpenILS::Utils::Penalty->calculate_penalties(
3021 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3023 $self->checkin_flesh_events;
3027 sub finish_fines_and_voiding {
3029 return unless $self->circ;
3031 return unless $self->backdate or $self->void_overdues;
3033 # void overdues after fine generation to prevent concurrent DB access to overdue billings
3034 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3036 my $evt = $CC->void_or_zero_overdues(
3037 $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3039 return $self->bail_on_events($evt) if $evt;
3041 # Make sure the circ is open or closed as necessary.
3042 $evt = $U->check_open_xact($self->editor, $self->circ->id);
3043 return $self->bail_on_events($evt) if $evt;
3049 # if a deposit was paid for this item, push the event
3050 # if called with a truthy param perform the void, depending on settings
3051 sub check_circ_deposit {
3055 return unless $self->circ;
3057 my $deposit = $self->editor->search_money_billing(
3059 xact => $self->circ->id,
3061 }, {idlist => 1})->[0];
3063 return unless $deposit;
3066 my $void_on_checkin = $U->ou_ancestor_setting_value(
3067 $self->circ_lib,OILS_SETTING_VOID_ITEM_DEPOSIT_ON_CHECKIN,$self->editor);
3068 if ( $void_on_checkin ) {
3069 my $evt = $CC->void_bills($self->editor,[$deposit], "DEPOSIT ITEM RETURNED");
3070 return $evt if $evt;
3072 } else { # if void is unset this is just a check, notify that there was a deposit billing
3073 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_PAID', payload => $deposit));
3079 my $force = $self->force || shift;
3080 my $copy = $self->copy;
3082 my $stat = $U->copy_status($copy->status)->id;
3084 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3087 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3088 $stat != OILS_COPY_STATUS_CATALOGING and
3089 $stat != OILS_COPY_STATUS_IN_TRANSIT and
3090 $stat != $next_status )) {
3092 $copy->status( $next_status );
3094 $self->checkin_changed(1);
3099 # Returns true if the item is at the current location
3100 # because it was transited there for a hold and the
3101 # hold has not been fulfilled
3102 sub checkin_check_holds_shelf {
3104 return 0 unless $self->copy;
3107 $U->copy_status($self->copy->status)->id ==
3108 OILS_COPY_STATUS_ON_HOLDS_SHELF;
3110 # Attempt to clear shelf expired holds for this copy
3111 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3112 if($self->clear_expired);
3114 # find the hold that put us on the holds shelf
3115 my $holds = $self->editor->search_action_hold_request(
3117 current_copy => $self->copy->id,
3118 capture_time => { '!=' => undef },
3119 fulfillment_time => undef,
3120 cancel_time => undef,
3125 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3126 $self->reshelve_copy(1);
3130 my $hold = $$holds[0];
3132 $logger->info("circulator: we found a captured, un-fulfilled hold [".
3133 $hold->id. "] for copy ".$self->copy->barcode);
3135 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3136 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3137 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3138 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3139 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3140 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3141 $self->fake_hold_dest(1);
3147 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3148 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3152 $logger->info("circulator: hold is not for here..");
3153 $self->remote_hold($hold);
3158 sub checkin_handle_precat {
3160 my $copy = $self->copy;
3162 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3163 $copy->status(OILS_COPY_STATUS_CATALOGING);
3164 $self->update_copy();
3165 $self->checkin_changed(1);
3166 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3171 sub checkin_build_copy_transit {
3174 my $copy = $self->copy;
3175 my $transit = Fieldmapper::action::transit_copy->new;
3177 # if we are transiting an item to the shelf shelf, it's a hold transit
3178 if (my $hold = $self->remote_hold) {
3179 $transit = Fieldmapper::action::hold_transit_copy->new;
3180 $transit->hold($hold->id);
3182 # the item is going into transit, remove any shelf-iness
3183 if ($hold->current_shelf_lib or $hold->shelf_time) {
3184 $hold->clear_current_shelf_lib;
3185 $hold->clear_shelf_time;
3186 return $self->bail_on_events($self->editor->event)
3187 unless $self->editor->update_action_hold_request($hold);
3191 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3192 $logger->info("circulator: transiting copy to $dest");
3194 $transit->source($self->circ_lib);
3195 $transit->dest($dest);
3196 $transit->target_copy($copy->id);
3197 $transit->source_send_time('now');
3198 $transit->copy_status( $U->copy_status($copy->status)->id );
3200 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3202 if ($self->remote_hold) {
3203 return $self->bail_on_events($self->editor->event)
3204 unless $self->editor->create_action_hold_transit_copy($transit);
3206 return $self->bail_on_events($self->editor->event)
3207 unless $self->editor->create_action_transit_copy($transit);
3210 # ensure the transit is returned to the caller
3211 $self->transit($transit);
3213 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3215 $self->checkin_changed(1);
3219 sub hold_capture_is_possible {
3221 my $copy = $self->copy;
3223 # we've been explicitly told not to capture any holds
3224 return 0 if $self->capture eq 'nocapture';
3226 # See if this copy can fulfill any holds
3227 my $hold = $holdcode->find_nearest_permitted_hold(
3228 $self->editor, $copy, $self->editor->requestor, 1 # check_only
3230 return undef if ref $hold eq "HASH" and
3231 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3235 sub reservation_capture_is_possible {
3237 my $copy = $self->copy;
3239 # we've been explicitly told not to capture any holds
3240 return 0 if $self->capture eq 'nocapture';
3242 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3243 my $resv = $booking_ses->request(
3244 "open-ils.booking.reservations.could_capture",
3245 $self->editor->authtoken, $copy->barcode
3247 $booking_ses->disconnect;
3248 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3249 $self->push_events($resv);
3255 # returns true if the item was used (or may potentially be used
3256 # in subsequent calls) to capture a hold.
3257 sub attempt_checkin_hold_capture {
3259 my $copy = $self->copy;
3261 # we've been explicitly told not to capture any holds
3262 return 0 if $self->capture eq 'nocapture';
3264 # See if this copy can fulfill any holds
3265 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3266 $self->editor, $copy, $self->editor->requestor );
3269 $logger->debug("circulator: no potential permitted".
3270 "holds found for copy ".$copy->barcode);
3274 if($self->capture ne 'capture') {
3275 # see if this item is in a hold-capture-delay location
3276 my $location = $self->copy->location;
3277 if(!ref($location)) {
3278 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3279 $self->copy->location($location);
3281 if($U->is_true($location->hold_verify)) {
3282 $self->bail_on_events(
3283 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3288 $self->retarget($retarget);
3290 my $suppress_transit = 0;
3291 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3292 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3293 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3294 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3295 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3296 $suppress_transit = 1;
3297 $hold->pickup_lib($self->circ_lib);
3302 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3304 $hold->current_copy($copy->id);
3305 $hold->capture_time('now');
3306 $self->put_hold_on_shelf($hold)
3307 if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3309 # prevent DB errors caused by fetching
3310 # holds from storage, and updating through cstore
3311 $hold->clear_fulfillment_time;
3312 $hold->clear_fulfillment_staff;
3313 $hold->clear_fulfillment_lib;
3314 $hold->clear_expire_time;
3315 $hold->clear_cancel_time;
3316 $hold->clear_prev_check_time unless $hold->prev_check_time;
3318 $self->bail_on_events($self->editor->event)
3319 unless $self->editor->update_action_hold_request($hold);
3321 $self->checkin_changed(1);
3323 return 0 if $self->bail_out;
3325 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3327 if ($hold->hold_type eq 'R') {
3328 $copy->status(OILS_COPY_STATUS_CATALOGING);
3329 $hold->fulfillment_time('now');
3330 $self->noop(1); # Block other transit/hold checks
3331 $self->bail_on_events($self->editor->event)
3332 unless $self->editor->update_action_hold_request($hold);
3334 # This hold was captured in the correct location
3335 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3336 $self->push_events(OpenILS::Event->new('SUCCESS'));
3338 #$self->do_hold_notify($hold->id);
3339 $self->notify_hold($hold->id);
3344 # Hold needs to be picked up elsewhere. Build a hold
3345 # transit and route the item.
3346 $self->checkin_build_hold_transit();
3347 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3348 return 0 if $self->bail_out;
3349 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3352 # make sure we save the copy status
3354 return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3358 sub attempt_checkin_reservation_capture {
3360 my $copy = $self->copy;
3362 # we've been explicitly told not to capture any holds
3363 return 0 if $self->capture eq 'nocapture';
3365 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3366 my $evt = $booking_ses->request(
3367 "open-ils.booking.resources.capture_for_reservation",
3368 $self->editor->authtoken,
3370 1 # don't update copy - we probably have it locked
3372 $booking_ses->disconnect;
3374 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3376 "open-ils.booking.resources.capture_for_reservation " .
3377 "didn't return an event!"
3381 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3382 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3384 # not-transferable is an error event we'll pass on the user
3385 $logger->warn("reservation capture attempted against non-transferable item");
3386 $self->push_events($evt);
3388 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3389 # Re-retrieve copy as reservation capture may have changed
3390 # its status and whatnot.
3392 "circulator: booking capture win on copy " . $self->copy->id
3394 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3396 "circulator: changing copy " . $self->copy->id .
3397 "'s status from " . $self->copy->status . " to " .
3400 $self->copy->status($new_copy_status);
3403 $self->reservation($evt->{"payload"}->{"reservation"});
3405 if (exists $evt->{"payload"}->{"transit"}) {
3409 "org" => $evt->{"payload"}->{"transit"}->dest
3413 $self->checkin_changed(1);
3417 # other results are treated as "nothing to capture"
3421 sub do_hold_notify {
3422 my( $self, $holdid ) = @_;
3424 my $e = new_editor(xact => 1);
3425 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3427 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3428 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3430 $logger->info("circulator: running delayed hold notify process");
3432 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3433 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3435 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3436 hold_id => $holdid, requestor => $self->editor->requestor);
3438 $logger->debug("circulator: built hold notifier");
3440 if(!$notifier->event) {
3442 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3444 my $stat = $notifier->send_email_notify;
3445 if( $stat == '1' ) {
3446 $logger->info("circulator: hold notify succeeded for hold $holdid");
3450 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3453 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3457 sub retarget_holds {
3459 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3460 my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3461 $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3462 # no reason to wait for the return value
3466 sub checkin_build_hold_transit {
3469 my $copy = $self->copy;
3470 my $hold = $self->hold;
3471 my $trans = Fieldmapper::action::hold_transit_copy->new;
3473 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3475 $trans->hold($hold->id);
3476 $trans->source($self->circ_lib);
3477 $trans->dest($hold->pickup_lib);
3478 $trans->source_send_time("now");
3479 $trans->target_copy($copy->id);
3481 # when the copy gets to its destination, it will recover
3482 # this status - put it onto the holds shelf
3483 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3485 return $self->bail_on_events($self->editor->event)
3486 unless $self->editor->create_action_hold_transit_copy($trans);
3491 sub process_received_transit {
3493 my $copy = $self->copy;
3494 my $copyid = $self->copy->id;
3496 my $status_name = $U->copy_status($copy->status)->name;
3497 $logger->debug("circulator: attempting transit receive on ".
3498 "copy $copyid. Copy status is $status_name");
3500 my $transit = $self->transit;
3502 # Check if we are in a transit suppress range
3503 my $suppress_transit = 0;
3504 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3505 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3506 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3507 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3508 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3509 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3510 $suppress_transit = 1;
3511 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3515 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3516 # - this item is in-transit to a different location
3517 # - Or we are capturing holds as transits, so why create a new transit?
3519 my $tid = $transit->id;
3520 my $loc = $self->circ_lib;
3521 my $dest = $transit->dest;
3523 $logger->info("circulator: Fowarding transit on copy which is destined ".
3524 "for a different location. transit=$tid, copy=$copyid, current ".
3525 "location=$loc, destination location=$dest");
3527 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3529 # grab the associated hold object if available
3530 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3531 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3533 return $self->bail_on_events($evt);
3536 # The transit is received, set the receive time
3537 $transit->dest_recv_time('now');
3538 $self->bail_on_events($self->editor->event)
3539 unless $self->editor->update_action_transit_copy($transit);
3541 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3543 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3544 $copy->status( $transit->copy_status );
3545 $self->update_copy();
3546 return if $self->bail_out;
3550 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3553 # hold has arrived at destination, set shelf time
3554 $self->put_hold_on_shelf($hold);
3555 $self->bail_on_events($self->editor->event)
3556 unless $self->editor->update_action_hold_request($hold);
3557 return if $self->bail_out;
3559 $self->notify_hold($hold_transit->hold);
3562 $hold_transit = undef;
3563 $self->cancelled_hold_transit(1);
3564 $self->reshelve_copy(1);
3565 $self->fake_hold_dest(0);
3570 OpenILS::Event->new(
3573 payload => { transit => $transit, holdtransit => $hold_transit } ));
3575 return $hold_transit;
3579 # ------------------------------------------------------------------
3580 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3581 # ------------------------------------------------------------------
3582 sub put_hold_on_shelf {
3583 my($self, $hold) = @_;
3584 $hold->shelf_time('now');
3585 $hold->current_shelf_lib($self->circ_lib);
3586 $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3592 my $reservation = shift;
3593 my $dt_parser = DateTime::Format::ISO8601->new;
3595 my $obj = $reservation ? $self->reservation : $self->circ;
3597 my $lost_bill_opts = $self->lost_bill_options;
3598 my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3599 # first, restore any voided overdues for lost, if needed
3600 if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3601 my $restore_od = $U->ou_ancestor_setting_value(
3602 $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3603 $self->editor) || 0;
3604 $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3608 # next, handle normal overdue generation and apply stop_fines
3609 # XXX reservations don't have stop_fines
3610 # TODO revisit booking_reservation re: stop_fines support
3611 if ($reservation or !$obj->stop_fines) {
3614 # This is a crude check for whether we are in a grace period. The code
3615 # in generate_fines() does a more thorough job, so this exists solely
3616 # as a small optimization, and might be better off removed.
3618 # If we have a grace period
3619 if($obj->can('grace_period')) {
3620 # Parse out the due date
3621 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($obj->due_date) );
3622 # Add the grace period to the due date
3623 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($obj->grace_period));
3624 # Don't generate fines on circs still in grace period
3625 $skip_for_grace = $due_date > DateTime->now;
3627 $CC->generate_fines({circs => [$obj], editor => $self->editor})
3628 unless $skip_for_grace;
3630 if (!$reservation and !$obj->stop_fines) {
3631 $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3632 $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3633 $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3634 $obj->stop_fines_time('now');
3635 $obj->stop_fines_time($self->backdate) if $self->backdate;
3636 $self->editor->update_action_circulation($obj);
3640 # finally, handle voiding of lost item and processing fees
3641 if ($self->needs_lost_bill_handling) {
3642 my $void_cost = $U->ou_ancestor_setting_value(
3643 $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3644 $self->editor) || 0;
3645 my $void_proc_fee = $U->ou_ancestor_setting_value(
3646 $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3647 $self->editor) || 0;
3648 $self->checkin_handle_lost_or_lo_now_found(
3649 $lost_bill_opts->{void_cost_btype},
3650 $lost_bill_opts->{is_longoverdue}) if $void_cost;
3651 $self->checkin_handle_lost_or_lo_now_found(
3652 $lost_bill_opts->{void_fee_btype},
3653 $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3659 sub checkin_handle_circ_start {
3661 my $circ = $self->circ;
3662 my $copy = $self->copy;
3666 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3668 # backdate the circ if necessary
3669 if($self->backdate) {
3670 my $evt = $self->checkin_handle_backdate;
3671 return $self->bail_on_events($evt) if $evt;
3674 # Set the checkin vars since we have the item
3675 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3677 # capture the true scan time for back-dated checkins
3678 $circ->checkin_scan_time('now');
3680 $circ->checkin_staff($self->editor->requestor->id);
3681 $circ->checkin_lib($self->circ_lib);
3682 $circ->checkin_workstation($self->editor->requestor->wsid);
3684 my $circ_lib = (ref $self->copy->circ_lib) ?
3685 $self->copy->circ_lib->id : $self->copy->circ_lib;
3686 my $stat = $U->copy_status($self->copy->status)->id;
3688 if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3689 # we will now handle lost fines, but the copy will retain its 'lost'
3690 # status if it needs to transit home unless lost_immediately_available
3693 # if we decide to also delay fine handling until the item arrives home,
3694 # we will need to call lost fine handling code both when checking items
3695 # in and also when receiving transits
3696 $self->checkin_handle_lost($circ_lib);
3697 } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3698 # same process as above.
3699 $self->checkin_handle_long_overdue($circ_lib);
3700 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3701 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3703 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3704 $self->copy->status($U->copy_status($next_status));
3711 sub checkin_handle_circ_finish {
3713 my $e = $self->editor;
3714 my $circ = $self->circ;
3716 # Do one last check before the final circulation update to see
3717 # if the xact_finish value should be set or not.
3719 # The underlying money.billable_xact may have been updated to
3720 # reflect a change in xact_finish during checkin bills handling,
3721 # however we can't simply refresh the circulation from the DB,
3722 # because other changes may be pending. Instead, reproduce the
3723 # xact_finish check here. It won't hurt to do it again.
3725 my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3726 if ($sum) { # is this test still needed?
3728 my $balance = $sum->balance_owed;
3730 if ($balance == 0) {
3731 $circ->xact_finish('now');
3733 $circ->clear_xact_finish;
3736 $logger->info("circulator: $balance is owed on this circulation");
3739 return $self->bail_on_events($e->event)
3740 unless $e->update_action_circulation($circ);
3745 # ------------------------------------------------------------------
3746 # See if we need to void billings, etc. for lost checkin
3747 # ------------------------------------------------------------------
3748 sub checkin_handle_lost {
3750 my $circ_lib = shift;
3752 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3753 OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3755 $self->lost_bill_options({
3756 circ_lib => $circ_lib,
3757 ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3758 ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3759 ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3760 void_cost_btype => 3,
3764 return $self->checkin_handle_lost_or_longoverdue(
3765 circ_lib => $circ_lib,
3766 max_return => $max_return,
3767 ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3768 ous_use_last_activity => undef # not supported for LOST checkin
3772 # ------------------------------------------------------------------
3773 # See if we need to void billings, etc. for long-overdue checkin
3774 # note: not using constants below since they serve little purpose
3775 # for single-use strings that are descriptive in their own right
3776 # and mostly just complicate debugging.
3777 # ------------------------------------------------------------------
3778 sub checkin_handle_long_overdue {
3780 my $circ_lib = shift;
3782 $logger->info("circulator: processing long-overdue checkin...");
3784 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3785 'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3787 $self->lost_bill_options({
3788 circ_lib => $circ_lib,
3789 ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3790 ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3791 is_longoverdue => 1,
3792 ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3793 void_cost_btype => 10,
3794 void_fee_btype => 11
3797 return $self->checkin_handle_lost_or_longoverdue(
3798 circ_lib => $circ_lib,
3799 max_return => $max_return,
3800 ous_immediately_available => 'circ.longoverdue_immediately_available',
3801 ous_use_last_activity =>
3802 'circ.longoverdue.use_last_activity_date_on_return'
3806 # last billing activity is last payment time, last billing time, or the
3807 # circ due date. If the relevant "use last activity" org unit setting is
3808 # false/unset, then last billing activity is always the due date.
3809 sub get_circ_last_billing_activity {
3811 my $circ_lib = shift;
3812 my $setting = shift;
3813 my $date = $self->circ->due_date;
3815 return $date unless $setting and
3816 $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3818 my $xact = $self->editor->retrieve_money_billable_transaction([
3820 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3823 if ($xact->summary) {
3824 $date = $xact->summary->last_payment_ts ||
3825 $xact->summary->last_billing_ts ||
3826 $self->circ->due_date;
3833 sub checkin_handle_lost_or_longoverdue {
3834 my ($self, %args) = @_;
3836 my $circ = $self->circ;
3837 my $max_return = $args{max_return};
3838 my $circ_lib = $args{circ_lib};
3843 $self->get_circ_last_billing_activity(
3844 $circ_lib, $args{ous_use_last_activity});
3847 my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3848 $tm[5] -= 1 if $tm[5] > 0;
3849 my $due = timelocal(int($tm[1]), int($tm[2]),
3850 int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3853 OpenILS::Utils::DateTime->interval_to_seconds($max_return) + int($due);
3855 $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3856 "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3857 "DUE: $due LAST: $last_chance");
3859 $max_return = 0 if $today < $last_chance;
3865 $logger->info("circulator: check-in of lost/lo item exceeds max ".
3866 "return interval. skipping fine/fee voiding, etc.");
3868 } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3870 $logger->info("circulator: check-in of lost/lo item having a balance ".
3871 "of zero, skipping fine/fee voiding and reinstatement.");
3873 } else { # within max-return interval or no interval defined
3875 $logger->info("circulator: check-in of lost/lo item is within the ".
3876 "max return interval (or no interval is defined). Proceeding ".
3877 "with fine/fee voiding, etc.");
3879 $self->needs_lost_bill_handling(1);
3882 if ($circ_lib != $self->circ_lib) {
3883 # if the item is not home, check to see if we want to retain the
3884 # lost/longoverdue status at this point in the process
3886 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib,
3887 $args{ous_immediately_available}, $self->editor) || 0;
3889 if ($immediately_available) {
3890 # item status does not need to be retained, so give it a
3891 # reshelving status as if it were a normal checkin
3892 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3893 $self->copy->status($U->copy_status($next_status));
3896 $logger->info("circulator: leaving lost/longoverdue copy".
3897 " status in place on checkin");
3900 # lost/longoverdue item is home and processed, treat like a normal
3901 # checkin from this point on
3902 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3903 $self->copy->status($U->copy_status($next_status));
3909 sub checkin_handle_backdate {
3912 # ------------------------------------------------------------------
3913 # clean up the backdate for date comparison
3914 # XXX We are currently taking the due-time from the original due-date,
3915 # not the input. Do we need to do this? This certainly interferes with
3916 # backdating of hourly checkouts, but that is likely a very rare case.
3917 # ------------------------------------------------------------------
3918 my $bd = clean_ISO8601($self->backdate);
3919 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->circ->due_date));
3920 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3921 $new_date->set_hour($original_date->hour());
3922 $new_date->set_minute($original_date->minute());
3923 if ($new_date >= DateTime->now) {
3924 # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3925 # $self->backdate() autoload handler ignores undef values.
3926 # Clear the backdate manually.
3927 $logger->info("circulator: ignoring future backdate: $new_date");
3928 delete $self->{backdate};
3930 $self->backdate(clean_ISO8601($new_date->datetime()));
3937 sub check_checkin_copy_status {
3939 my $copy = $self->copy;
3941 my $status = $U->copy_status($copy->status)->id;
3944 if( $self->new_copy_alerts ||
3945 $status == OILS_COPY_STATUS_AVAILABLE ||
3946 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3947 $status == OILS_COPY_STATUS_IN_PROCESS ||
3948 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3949 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3950 $status == OILS_COPY_STATUS_CATALOGING ||
3951 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3952 $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
3953 $status == OILS_COPY_STATUS_RESHELVING );
3955 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3956 if( $status == OILS_COPY_STATUS_LOST );
3958 return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
3959 if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
3961 return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
3962 if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
3964 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3965 if( $status == OILS_COPY_STATUS_MISSING );
3967 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3972 # --------------------------------------------------------------------------
3973 # On checkin, we need to return as many relevant objects as we can
3974 # --------------------------------------------------------------------------
3975 sub checkin_flesh_events {
3978 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3979 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3980 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3983 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3986 if($self->hold and !$self->hold->cancel_time) {
3987 $hold = $self->hold;
3988 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3992 # update our copy of the circ object and
3993 # flesh the billing summary data
3995 $self->editor->retrieve_action_circulation([
3999 circ => ['billable_transaction'],
4008 # flesh some patron fields before returning
4010 $self->editor->retrieve_actor_user([
4015 au => ['card', 'billing_address', 'mailing_address']
4022 if ($self->latest_inventory) {
4023 # flesh some workstation fields before returning
4024 $self->latest_inventory->inventory_workstation(
4025 $self->editor->retrieve_actor_workstation([$self->latest_inventory->inventory_workstation])
4029 if($self->latest_inventory && !$self->latest_inventory->id) {
4030 my $alci = $self->editor->search_asset_latest_inventory(
4031 {copy => $self->latest_inventory->copy}
4034 $self->latest_inventory->id($alci->[0]->id);
4037 $self->copy->latest_inventory($self->latest_inventory);
4039 for my $evt (@{$self->events}) {
4042 $payload->{copy} = $U->unflesh_copy($self->copy);
4043 $payload->{volume} = $self->volume;
4044 $payload->{record} = $record,
4045 $payload->{circ} = $self->circ;
4046 $payload->{transit} = $self->transit;
4047 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
4048 $payload->{hold} = $hold;
4049 $payload->{patron} = $self->patron;
4050 $payload->{reservation} = $self->reservation
4051 unless (not $self->reservation or $self->reservation->cancel_time);
4052 $payload->{latest_inventory} = $self->latest_inventory;
4053 if ($self->do_inventory_update) { $payload->{do_inventory_update} = 1; }
4055 $evt->{payload} = $payload;
4060 my( $self, $msg ) = @_;
4061 my $bc = ($self->copy) ? $self->copy->barcode :
4064 my $usr = ($self->patron) ? $self->patron->id : "";
4065 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
4066 ", recipient=$usr, copy=$bc");
4073 $self->log_me("do_renew()");
4075 # Make sure there is an open circ to renew
4076 my $usrid = $self->patron->id if $self->patron;
4077 my $circ = $self->editor->search_action_circulation({
4078 target_copy => $self->copy->id,
4079 xact_finish => undef,
4080 checkin_time => undef,
4081 ($usrid ? (usr => $usrid) : ())
4084 return $self->bail_on_events($self->editor->event) unless $circ;
4086 # A user is not allowed to renew another user's items without permission
4087 unless( $circ->usr eq $self->editor->requestor->id ) {
4088 return $self->bail_on_events($self->editor->events)
4089 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
4092 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
4093 if $circ->renewal_remaining < 1;
4095 $self->push_events(OpenILS::Event->new('MAX_AUTO_RENEWALS_REACHED'))
4096 if $self->auto_renewal and $circ->auto_renewal_remaining < 1;
4097 # -----------------------------------------------------------------
4099 $self->parent_circ($circ->id);
4100 $self->renewal_remaining( $circ->renewal_remaining - 1 );
4101 $self->auto_renewal_remaining( $circ->auto_renewal_remaining - 1 ) if (defined($circ->auto_renewal_remaining));
4104 # Opac renewal - re-use circ library from original circ (unless told not to)
4105 if($self->opac_renewal or $self->auto_renewal) {
4106 unless(defined($opac_renewal_use_circ_lib)) {
4107 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
4108 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4109 $opac_renewal_use_circ_lib = 1;
4112 $opac_renewal_use_circ_lib = 0;
4115 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4118 # Desk renewal - re-use circ library from original circ (unless told not to)
4119 if($self->desk_renewal) {
4120 unless(defined($desk_renewal_use_circ_lib)) {
4121 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4122 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4123 $desk_renewal_use_circ_lib = 1;
4126 $desk_renewal_use_circ_lib = 0;
4129 $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
4132 # Check if expired patron is allowed to renew, and bail if not.
4133 my $expire = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->patron->expire_date));
4134 if (CORE::time > $expire->epoch) {
4135 my $allow_renewal = $U->ou_ancestor_setting_value($self->circ_lib, OILS_SETTING_ALLOW_RENEW_FOR_EXPIRED_PATRON);
4136 unless ($U->is_true($allow_renewal)) {
4137 return $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'));
4141 # Run the fine generator against the old circ
4142 # XXX This seems unnecessary, given that handle_fines runs in do_checkin
4143 # a few lines down. Commenting out, for now.
4144 #$self->handle_fines;
4146 $self->run_renew_permit;
4149 $self->do_checkin();
4150 return if $self->bail_out;
4152 unless( $self->permit_override ) {
4154 return if $self->bail_out;
4155 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
4156 $self->remove_event('ITEM_NOT_CATALOGED');
4159 $self->override_events;
4160 return if $self->bail_out;
4163 $self->do_checkout();
4168 my( $self, $evt ) = @_;
4169 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4170 $logger->debug("circulator: removing event from list: $evt");
4171 my @events = @{$self->events};
4172 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
4177 my( $self, $evt ) = @_;
4178 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4179 return grep { $_->{textcode} eq $evt } @{$self->events};
4183 sub run_renew_permit {
4186 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
4187 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
4188 $self->editor, $self->copy, $self->editor->requestor, 1
4190 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
4193 my $results = $self->run_indb_circ_test;
4194 $self->push_events($self->matrix_test_result_events)
4195 unless $self->circ_test_success;
4199 # XXX: The primary mechanism for storing circ history is now handled
4200 # by tracking real circulation objects instead of bibs in a bucket.
4201 # However, this code is disabled by default and could be useful
4202 # some day, so may as well leave it for now.
4203 sub append_reading_list {
4207 $self->is_checkout and
4213 # verify history is globally enabled and uses the bucket mechanism
4214 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
4215 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
4217 return undef unless $htype and $htype eq 'bucket';
4219 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
4221 # verify the patron wants to retain the hisory
4222 my $setting = $e->search_actor_user_setting(
4223 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
4225 unless($setting and $setting->value) {
4230 my $bkt = $e->search_container_copy_bucket(
4231 {owner => $self->patron->id, btype => 'circ_history'})->[0];
4236 # find the next item position
4237 my $last_item = $e->search_container_copy_bucket_item(
4238 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
4239 $pos = $last_item->pos + 1 if $last_item;
4242 # create the history bucket if necessary
4243 $bkt = Fieldmapper::container::copy_bucket->new;
4244 $bkt->owner($self->patron->id);
4246 $bkt->btype('circ_history');
4248 $e->create_container_copy_bucket($bkt) or return $e->die_event;
4251 my $item = Fieldmapper::container::copy_bucket_item->new;
4253 $item->bucket($bkt->id);
4254 $item->target_copy($self->copy->id);
4257 $e->create_container_copy_bucket_item($item) or return $e->die_event;
4264 sub make_trigger_events {
4266 return unless $self->circ;
4267 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
4268 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
4269 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
4274 sub checkin_handle_lost_or_lo_now_found {
4275 my ($self, $bill_type, $is_longoverdue) = @_;
4277 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4279 $logger->debug("voiding $tag item billings");
4280 my $result = $CC->void_or_zero_bills_of_type($self->editor, $self->circ, $self->copy, $bill_type, "$tag ITEM RETURNED");
4281 $self->bail_on_events($self->editor->event) if ($result);
4284 sub checkin_handle_lost_or_lo_now_found_restore_od {
4286 my $circ_lib = shift;
4287 my $is_longoverdue = shift;
4288 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4290 # ------------------------------------------------------------------
4291 # restore those overdue charges voided when item was set to lost
4292 # ------------------------------------------------------------------
4294 my $ods = $self->editor->search_money_billing([
4296 xact => $self->circ->id,
4300 order_by => {mb => 'billing_ts desc'}
4304 $logger->debug("returning ".scalar(@$ods)." overdue charges pre-$tag");
4305 # Because actual users get up to all kinds of unexpectedness, we
4306 # only recreate up to $circ->max_fine in bills. I know you think
4307 # it wouldn't happen that bills could get created, voided, and
4308 # recreated more than once, but I guaran-damn-tee you that it will
4310 if ($ods && @$ods) {
4311 my $void_amount = 0;
4312 my $void_max = $self->circ->max_fine();
4313 # search for overdues voided the new way (aka "adjusted")
4314 my @billings = map {$_->id()} @$ods;
4315 my $voids = $self->editor->search_money_account_adjustment(
4317 billing => \@billings
4321 map {$void_amount += $_->amount()} @$voids;
4323 # if no adjustments found, assume they were voided the old way (aka "voided")
4324 for my $bill (@$ods) {
4325 if( $U->is_true($bill->voided) ) {
4326 $void_amount += $bill->amount();
4332 ($void_amount < $void_max ? $void_amount : $void_max),
4334 $ods->[0]->billing_type(),
4336 "System: $tag RETURNED - OVERDUES REINSTATED",
4337 $ods->[-1]->period_start(),
4338 $ods->[0]->period_end() # date this restoration the same as the last overdue (for possible subsequent fine generation)