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/
416 overrides_per_copy_alerts
457 recurring_fines_level
462 auto_renewal_remaining
471 cancelled_hold_transit
479 circ_matrix_matchpoint
490 claims_never_checked_out
503 dont_change_lost_zero
505 needs_lost_bill_handling
511 my $type = ref($self) or die "$self is not an object";
513 my $name = $AUTOLOAD;
516 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
517 $logger->error("circulator: $type: invalid autoload field: $name");
518 die "$type: invalid autoload field: $name\n"
523 *{"${type}::${name}"} = sub {
526 $s->{$name} = $v if defined $v;
530 return $self->$name($data);
535 my( $class, $auth, %args ) = @_;
536 $class = ref($class) || $class;
537 my $self = bless( {}, $class );
540 $self->editor(new_editor(xact => 1, authtoken => $auth));
542 unless( $self->editor->checkauth ) {
543 $self->bail_on_events($self->editor->event);
547 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
549 $self->$_($args{$_}) for keys %args;
552 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
554 # if this is a renewal, default to desk_renewal
555 $self->desk_renewal(1) unless
556 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal
557 or $self->auto_renewal;
559 $self->capture('') unless $self->capture;
561 unless(%user_groups) {
562 my $gps = $self->editor->retrieve_all_permission_grp_tree;
563 %user_groups = map { $_->id => $_ } @$gps;
570 # --------------------------------------------------------------------------
571 # True if we should discontinue processing
572 # --------------------------------------------------------------------------
574 my( $self, $bool ) = @_;
575 if( defined $bool ) {
576 $logger->info("circulator: BAILING OUT") if $bool;
577 $self->{bail_out} = $bool;
579 return $self->{bail_out};
584 my( $self, @evts ) = @_;
587 $e->{payload} = $self->copy if
588 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
590 $logger->info("circulator: pushing event ".$e->{textcode});
591 push( @{$self->events}, $e ) unless
592 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
598 return '' if $self->skip_permit_key;
599 my $key = md5_hex( time() . rand() . "$$" );
600 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
601 return $self->permit_key($key);
604 sub check_permit_key {
606 return 1 if $self->skip_permit_key;
607 my $key = $self->permit_key;
608 return 0 unless $key;
609 my $k = "oils_permit_key_$key";
610 my $one = $self->cache_handle->get_cache($k);
611 $self->cache_handle->delete_cache($k);
612 return ($one) ? 1 : 0;
615 sub seems_like_reservation {
618 # Some words about the following method:
619 # 1) It requires the VIEW_USER permission, but that's not an
620 # issue, right, since all staff should have that?
621 # 2) It returns only one reservation at a time, even if an item can be
622 # and is currently overbooked. Hmmm....
623 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
624 my $result = $booking_ses->request(
625 "open-ils.booking.reservations.by_returnable_resource_barcode",
626 $self->editor->authtoken,
629 $booking_ses->disconnect;
631 return $self->bail_on_events($result) if defined $U->event_code($result);
634 $self->reservation(shift @$result);
642 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
643 sub save_trimmed_copy {
644 my ($self, $copy) = @_;
647 $self->volume($copy->call_number);
648 $self->title($self->volume->record);
649 $self->copy->call_number($self->volume->id);
650 $self->volume->record($self->title->id);
651 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
652 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
653 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
654 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
658 sub collect_user_copy_alerts {
660 my $e = $self->editor;
663 my $alerts = $e->search_asset_copy_alert([
664 {copy => $self->copy->id, ack_time => undef},
665 {flesh => 1, flesh_fields => { aca => [ qw/ alert_type / ] }}
667 if (ref $alerts eq "ARRAY") {
668 $logger->info("circulator: found " . scalar(@$alerts) . " alerts for copy " .
670 $self->user_copy_alerts($alerts);
675 sub filter_user_copy_alerts {
678 my $e = $self->editor;
680 if(my $alerts = $self->user_copy_alerts) {
682 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
683 my $suppressions = $e->search_actor_copy_alert_suppress(
684 {org => $suppress_orgs}
688 foreach my $a (@$alerts) {
689 # filter on event type
690 if (defined $a->alert_type) {
691 next if ($a->alert_type->event eq 'CHECKIN' && !$self->is_checkin && !$self->is_renewal);
692 next if ($a->alert_type->event eq 'CHECKOUT' && !$self->is_checkout && !$self->is_renewal);
693 next if (defined $a->alert_type->in_renew && $U->is_true($a->alert_type->in_renew) && !$self->is_renewal);
694 next if (defined $a->alert_type->in_renew && !$U->is_true($a->alert_type->in_renew) && $self->is_renewal);
697 # filter on suppression
698 next if (grep { $a->alert_type->id == $_->alert_type} @$suppressions);
700 # filter on "only at circ lib"
701 if (defined $a->alert_type->at_circ) {
702 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
703 $self->copy->circ_lib->id : $self->copy->circ_lib;
704 my $orgs = $U->get_org_descendants($copy_circ_lib);
706 if ($U->is_true($a->alert_type->invert_location)) {
707 next if (grep {$_ == $self->circ_lib} @$orgs);
709 next unless (grep {$_ == $self->circ_lib} @$orgs);
713 # filter on "only at owning lib"
714 if (defined $a->alert_type->at_owning) {
715 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
716 $self->volume->owning_lib->id : $self->volume->owning_lib;
717 my $orgs = $U->get_org_descendants($copy_owning_lib);
719 if ($U->is_true($a->alert_type->invert_location)) {
720 next if (grep {$_ == $self->circ_lib} @$orgs);
722 next unless (grep {$_ == $self->circ_lib} @$orgs);
726 $a->alert_type->next_status([$U->unique_unnested_numbers($a->alert_type->next_status)]);
728 push @final_alerts, $a;
731 $self->user_copy_alerts(\@final_alerts);
735 sub generate_system_copy_alerts {
737 return unless($self->copy);
739 # don't create system copy alerts if the copy
740 # is in a normal state; we're assuming that there's
741 # never a need to generate a popup for each and every
742 # checkin or checkout of normal items. If this assumption
743 # proves false, then we'll need to add a way to explicitly specify
744 # that a copy alert type should never generate a system copy alert
745 return if $self->copy_state eq 'NORMAL';
747 my $e = $self->editor;
749 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
750 my $suppressions = $e->search_actor_copy_alert_suppress(
751 {org => $suppress_orgs}
754 # events we care about ...
756 push(@$event, 'CHECKIN') if $self->is_checkin;
757 push(@$event, 'CHECKOUT') if $self->is_checkout;
758 return unless scalar(@$event);
760 my $alert_orgs = $U->get_org_ancestors($self->circ_lib);
761 my $alert_types = $e->search_config_copy_alert_type({
763 scope_org => $alert_orgs,
765 state => $self->copy_state,
766 '-or' => [ { in_renew => $self->is_renewal }, { in_renew => undef } ],
770 foreach my $a (@$alert_types) {
771 # filter on "only at circ lib"
772 if (defined $a->at_circ) {
773 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
774 $self->copy->circ_lib->id : $self->copy->circ_lib;
775 my $orgs = $U->get_org_descendants($copy_circ_lib);
777 if ($U->is_true($a->invert_location)) {
778 next if (grep {$_ == $self->circ_lib} @$orgs);
780 next unless (grep {$_ == $self->circ_lib} @$orgs);
784 # filter on "only at owning lib"
785 if (defined $a->at_owning) {
786 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
787 $self->volume->owning_lib->id : $self->volume->owning_lib;
788 my $orgs = $U->get_org_descendants($copy_owning_lib);
790 if ($U->is_true($a->invert_location)) {
791 next if (grep {$_ == $self->circ_lib} @$orgs);
793 next unless (grep {$_ == $self->circ_lib} @$orgs);
797 push @final_types, $a;
801 $logger->info("circulator: found " . scalar(@final_types) . " system alert types for copy" .
807 # keep track of conditions corresponding to suppressed
808 # system alerts, as these may be used to overridee
809 # certain old-style-events
810 my %auto_override_conditions = ();
811 foreach my $t (@final_types) {
812 if ($t->next_status) {
813 if (grep { $t->id == $_->alert_type } @$suppressions) {
816 $t->next_status([$U->unique_unnested_numbers($t->next_status)]);
820 my $alert = new Fieldmapper::asset::copy_alert ();
821 $alert->alert_type($t->id);
822 $alert->copy($self->copy->id);
824 $alert->create_staff($e->requestor->id);
825 $alert->create_time('now');
826 $alert->ack_staff($e->requestor->id);
827 $alert->ack_time('now');
829 $alert = $e->create_asset_copy_alert($alert);
833 $alert->alert_type($t->clone);
835 push(@{$self->next_copy_status}, @{$t->next_status}) if ($t->next_status);
836 if (grep {$_->alert_type == $t->id} @$suppressions) {
837 $auto_override_conditions{join("\t", $t->state, $t->event)} = 1;
839 push(@alerts, $alert) unless (grep {$_->alert_type == $t->id} @$suppressions);
842 $self->system_copy_alerts(\@alerts);
843 $self->overrides_per_copy_alerts(\%auto_override_conditions);
846 sub add_overrides_from_system_copy_alerts {
848 my $e = $self->editor;
850 foreach my $condition (keys %{$self->overrides_per_copy_alerts()}) {
851 if (exists $COPY_ALERT_OVERRIDES{$condition}) {
853 push @{$self->override_args->{events}}, @{ $COPY_ALERT_OVERRIDES{$condition} };
854 # special handling for long-overdue and lost checkouts
855 if (grep { $_ eq 'OPEN_CIRCULATION_EXISTS' } @{ $COPY_ALERT_OVERRIDES{$condition} }) {
856 my $state = (split /\t/, $condition, -1)[0];
858 if ($state eq 'LOST' or $state eq 'LOST_AND_PAID') {
859 $setting = 'circ.copy_alerts.forgive_fines_on_lost_checkin';
860 } elsif ($state eq 'LONGOVERDUE') {
861 $setting = 'circ.copy_alerts.forgive_fines_on_long_overdue_checkin';
865 my $forgive = $U->ou_ancestor_setting_value(
866 $self->circ_lib, $setting, $e
868 if ($U->is_true($forgive)) {
869 $self->void_overdues(1);
871 $self->noop(1); # do not attempt transits, just check it in
880 my $e = $self->editor;
882 $self->next_copy_status([]) unless (defined $self->next_copy_status);
883 $self->overrides_per_copy_alerts({}) unless (defined $self->overrides_per_copy_alerts);
885 # --------------------------------------------------------------------------
886 # Grab the fleshed copy
887 # --------------------------------------------------------------------------
888 unless($self->is_noncat) {
891 $copy = $e->retrieve_asset_copy(
892 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
894 } elsif( $self->copy_barcode ) {
896 $copy = $e->search_asset_copy(
897 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
898 } elsif( $self->reservation ) {
899 my $res = $e->json_query(
901 "select" => {"acp" => ["id"]},
906 "field" => "barcode",
910 "field" => "current_resource"
919 "id" => (ref $self->reservation) ?
920 $self->reservation->id : $self->reservation
925 if (ref $res eq "ARRAY" and scalar @$res) {
926 $logger->info("circulator: mapped reservation " .
927 $self->reservation . " to copy " . $res->[0]->{"id"});
928 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
933 $self->save_trimmed_copy($copy);
938 {from => ['asset.copy_state', $copy->id]}
939 )->[0]{'asset.copy_state'}
942 $self->generate_system_copy_alerts;
943 $self->add_overrides_from_system_copy_alerts;
944 $self->collect_user_copy_alerts;
945 $self->filter_user_copy_alerts;
948 # We can't renew if there is no copy
949 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
950 if $self->is_renewal;
955 # --------------------------------------------------------------------------
957 # --------------------------------------------------------------------------
961 flesh_fields => {au => [ qw/ card / ]}
964 if( $self->patron_id ) {
965 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
966 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
968 } elsif( $self->patron_barcode ) {
970 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
971 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
972 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
974 $patron = $e->retrieve_actor_user($card->usr)
975 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
977 # Use the card we looked up, not the patron's primary, for card active checks
978 $patron->card($card);
981 if( my $copy = $self->copy ) {
984 $flesh->{flesh_fields}->{circ} = ['usr'];
986 my $circ = $e->search_action_circulation([
987 {target_copy => $copy->id, checkin_time => undef}, $flesh
991 $patron = $circ->usr;
992 $circ->usr($patron->id); # de-flesh for consistency
998 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
999 unless $self->patron($patron) or $self->is_checkin;
1001 unless($self->is_checkin) {
1003 # Check for inactivity and patron reg. expiration
1005 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
1006 unless $U->is_true($patron->active);
1008 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
1009 unless $U->is_true($patron->card->active);
1011 # Expired patrons cannot check out. Renewals for expired
1012 # patrons depend on a setting and will be checked in the
1013 # do_renew subroutine.
1014 if ($self->is_checkout) {
1015 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1016 clean_ISO8601($patron->expire_date));
1018 if (CORE::time > $expire->epoch) {
1019 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1026 # --------------------------------------------------------------------------
1027 # Does the circ permit work
1028 # --------------------------------------------------------------------------
1032 $self->log_me("do_permit()");
1034 unless( $self->editor->requestor->id == $self->patron->id ) {
1035 return $self->bail_on_events($self->editor->event)
1036 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1039 $self->check_captured_holds();
1040 $self->do_copy_checks();
1041 return if $self->bail_out;
1042 $self->run_patron_permit_scripts();
1043 $self->run_copy_permit_scripts()
1044 unless $self->is_precat or $self->is_noncat;
1045 $self->check_item_deposit_events();
1046 $self->override_events();
1047 return if $self->bail_out;
1049 if($self->is_precat and not $self->request_precat) {
1051 OpenILS::Event->new(
1052 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1053 return $self->bail_out(1) unless $self->is_renewal;
1057 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1060 sub check_item_deposit_events {
1062 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
1063 if $self->is_deposit and not $self->is_deposit_exempt;
1064 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
1065 if $self->is_rental and not $self->is_rental_exempt;
1068 # returns true if the user is not required to pay deposits
1069 sub is_deposit_exempt {
1071 my $pid = (ref $self->patron->profile) ?
1072 $self->patron->profile->id : $self->patron->profile;
1073 my $groups = $U->ou_ancestor_setting_value(
1074 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1075 for my $grp (@$groups) {
1076 return 1 if $self->is_group_descendant($grp, $pid);
1081 # returns true if the user is not required to pay rental fees
1082 sub is_rental_exempt {
1084 my $pid = (ref $self->patron->profile) ?
1085 $self->patron->profile->id : $self->patron->profile;
1086 my $groups = $U->ou_ancestor_setting_value(
1087 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1088 for my $grp (@$groups) {
1089 return 1 if $self->is_group_descendant($grp, $pid);
1094 sub is_group_descendant {
1095 my($self, $p_id, $c_id) = @_;
1096 return 0 unless defined $p_id and defined $c_id;
1097 return 1 if $c_id == $p_id;
1098 while(my $grp = $user_groups{$c_id}) {
1099 $c_id = $grp->parent;
1100 return 0 unless defined $c_id;
1101 return 1 if $c_id == $p_id;
1106 sub check_captured_holds {
1108 my $copy = $self->copy;
1109 my $patron = $self->patron;
1111 return undef unless $copy;
1113 my $s = $U->copy_status($copy->status)->id;
1114 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1115 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1117 # Item is on the holds shelf, make sure it's going to the right person
1118 my $hold = $self->editor->search_action_hold_request(
1121 current_copy => $copy->id ,
1122 capture_time => { '!=' => undef },
1123 cancel_time => undef,
1124 fulfillment_time => undef
1128 flesh_fields => { ahr => ['usr'] }
1133 if ($hold and $hold->usr->id == $patron->id) {
1134 $self->checkout_is_for_hold(1);
1138 my $holdau = $hold->usr;
1141 $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1142 $payload->{patron_id} = $holdau->id;
1144 $payload->{patron_name} = "???";
1146 $payload->{hold_id} = $hold->id;
1147 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1148 payload => $payload));
1151 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1156 sub do_copy_checks {
1158 my $copy = $self->copy;
1159 return unless $copy;
1161 my $stat = $U->copy_status($copy->status)->id;
1163 # We cannot check out a copy if it is in-transit
1164 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1165 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1168 $self->handle_claims_returned();
1169 return if $self->bail_out;
1171 # no claims returned circ was found, check if there is any open circ
1172 unless( $self->is_renewal ) {
1174 my $circs = $self->editor->search_action_circulation(
1175 { target_copy => $copy->id, checkin_time => undef }
1178 if(my $old_circ = $circs->[0]) { # an open circ was found
1180 my $payload = {copy => $copy};
1182 if($old_circ->usr == $self->patron->id) {
1184 $payload->{old_circ} = $old_circ;
1186 # If there is an open circulation on the checkout item and an auto-renew
1187 # interval is defined, inform the caller that they should go
1188 # ahead and renew the item instead of warning about open circulations.
1190 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1192 'circ.checkout_auto_renew_age',
1196 if($auto_renew_intvl) {
1197 my $intvl_seconds = OpenILS::Utils::DateTime->interval_to_seconds($auto_renew_intvl);
1198 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( clean_ISO8601($old_circ->xact_start) );
1200 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1201 $payload->{auto_renew} = 1;
1206 return $self->bail_on_events(
1207 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1213 my $LEGACY_CIRC_EVENT_MAP = {
1214 'no_item' => 'ITEM_NOT_CATALOGED',
1215 'actor.usr.barred' => 'PATRON_BARRED',
1216 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1217 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1218 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1219 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1220 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1221 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1222 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1223 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1224 'config.circ_matrix_test.total_copy_hold_ratio' =>
1225 'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1226 'config.circ_matrix_test.available_copy_hold_ratio' =>
1227 'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1231 # ---------------------------------------------------------------------
1232 # This pushes any patron-related events into the list but does not
1233 # set bail_out for any events
1234 # ---------------------------------------------------------------------
1235 sub run_patron_permit_scripts {
1237 my $patronid = $self->patron->id;
1242 my $results = $self->run_indb_circ_test;
1243 unless($self->circ_test_success) {
1244 my @trimmed_results;
1246 if ($self->is_noncat) {
1247 # no_item result is OK during noncat checkout
1248 @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1252 if ($self->checkout_is_for_hold) {
1253 # if this checkout will fulfill a hold, ignore CIRC blocks
1254 # and rely instead on the (later-checked) FULFILL block
1256 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1257 my $fblock_pens = $self->editor->search_config_standing_penalty(
1258 {name => [@pen_names], block_list => {like => '%CIRC%'}});
1260 for my $res (@$results) {
1261 my $name = $res->{fail_part} || '';
1262 next if grep {$_->name eq $name} @$fblock_pens;
1263 push(@trimmed_results, $res);
1267 # not for hold or noncat
1268 @trimmed_results = @$results;
1272 # update the final set of test results
1273 $self->matrix_test_result(\@trimmed_results);
1275 push @allevents, $self->matrix_test_result_events;
1279 $_->{payload} = $self->copy if
1280 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1283 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1285 $self->push_events(@allevents);
1288 sub matrix_test_result_codes {
1290 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1293 sub matrix_test_result_events {
1296 my $event = new OpenILS::Event(
1297 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1299 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1301 } (@{$self->matrix_test_result});
1304 sub run_indb_circ_test {
1306 return $self->matrix_test_result if $self->matrix_test_result;
1308 # Before we run the database function, let's make sure that the patron's
1309 # threshold-based penalties are up-to-date, so that the database function
1310 # can take them into consideration.
1311 my @threshold_based_penalties = qw(PATRON_EXCEEDS_FINES PATRON_EXCEEDS_OVERDUE_COUNT PATRON_EXCEEDS_CHECKOUT_COUNT PATRON_EXCEEDS_LOST_COUNT);
1312 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib, @threshold_based_penalties);
1314 my $dbfunc = ($self->is_renewal) ?
1315 'action.item_user_renew_test' : 'action.item_user_circ_test';
1317 if( $self->is_precat && $self->request_precat) {
1318 $self->make_precat_copy;
1319 return if $self->bail_out;
1322 my $results = $self->editor->json_query(
1326 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1332 $self->circ_test_success($U->is_true($results->[0]->{success}));
1334 if(my $mp = $results->[0]->{matchpoint}) {
1335 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1336 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1337 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1338 if(defined($results->[0]->{renewals})) {
1339 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1341 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1342 if(defined($results->[0]->{grace_period})) {
1343 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1345 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1346 if(defined($results->[0]->{hard_due_date})) {
1347 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1349 # Grab the *last* response for limit_groups, where it is more likely to be filled
1350 $self->limit_groups($results->[-1]->{limit_groups});
1353 return $self->matrix_test_result($results);
1356 # ---------------------------------------------------------------------
1357 # given a use and copy, this will calculate the circulation policy
1358 # parameters. Only works with in-db circ.
1359 # ---------------------------------------------------------------------
1363 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1365 $self->run_indb_circ_test;
1368 circ_test_success => $self->circ_test_success,
1369 failure_events => [],
1370 failure_codes => [],
1371 matchpoint => $self->circ_matrix_matchpoint
1374 unless($self->circ_test_success) {
1375 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1376 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1379 if($self->circ_matrix_matchpoint) {
1380 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1381 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1382 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1383 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1385 my $policy = $self->get_circ_policy(
1386 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1388 $$results{$_} = $$policy{$_} for keys %$policy;
1394 # ---------------------------------------------------------------------
1395 # Loads the circ policy info for duration, recurring fine, and max
1396 # fine based on the current copy
1397 # ---------------------------------------------------------------------
1398 sub get_circ_policy {
1399 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1402 duration_rule => $duration_rule->name,
1403 recurring_fine_rule => $recurring_fine_rule->name,
1404 max_fine_rule => $max_fine_rule->name,
1405 max_fine => $self->get_max_fine_amount($max_fine_rule),
1406 fine_interval => $recurring_fine_rule->recurrence_interval,
1407 renewal_remaining => $duration_rule->max_renewals,
1408 auto_renewal_remaining => $duration_rule->max_auto_renewals,
1409 grace_period => $recurring_fine_rule->grace_period
1412 if($hard_due_date) {
1413 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1414 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1417 $policy->{duration_date_ceiling} = undef;
1418 $policy->{duration_date_ceiling_force} = undef;
1421 $policy->{duration} = $duration_rule->shrt
1422 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1423 $policy->{duration} = $duration_rule->normal
1424 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1425 $policy->{duration} = $duration_rule->extended
1426 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1428 $policy->{recurring_fine} = $recurring_fine_rule->low
1429 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1430 $policy->{recurring_fine} = $recurring_fine_rule->normal
1431 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1432 $policy->{recurring_fine} = $recurring_fine_rule->high
1433 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1438 sub get_max_fine_amount {
1440 my $max_fine_rule = shift;
1441 my $max_amount = $max_fine_rule->amount;
1443 # if is_percent is true then the max->amount is
1444 # use as a percentage of the copy price
1445 if ($U->is_true($max_fine_rule->is_percent)) {
1446 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1447 $max_amount = $price * $max_fine_rule->amount / 100;
1449 $U->ou_ancestor_setting_value(
1451 'circ.max_fine.cap_at_price',
1455 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1456 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1464 sub run_copy_permit_scripts {
1466 my $copy = $self->copy || return;
1470 my $results = $self->run_indb_circ_test;
1471 push @allevents, $self->matrix_test_result_events
1472 unless $self->circ_test_success;
1474 # See if this copy has an alert message
1475 my $ae = $self->check_copy_alert();
1476 push( @allevents, $ae ) if $ae;
1478 # uniquify the events
1479 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1480 @allevents = values %hash;
1482 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1484 $self->push_events(@allevents);
1488 sub check_copy_alert {
1491 if ($self->new_copy_alerts) {
1493 push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts
1494 if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1496 push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts
1497 if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1500 $self->bail_out(1) if (!$self->override);
1501 return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1505 return undef if $self->is_renewal;
1506 return OpenILS::Event->new(
1507 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1508 if $self->copy and $self->copy->alert_message;
1514 # --------------------------------------------------------------------------
1515 # If the call is overriding and has permissions to override every collected
1516 # event, the are cleared. Any event that the caller does not have
1517 # permission to override, will be left in the event list and bail_out will
1519 # XXX We need code in here to cancel any holds/transits on copies
1520 # that are being force-checked out
1521 # --------------------------------------------------------------------------
1522 sub override_events {
1524 my @events = @{$self->events};
1525 return unless @events;
1526 my $oargs = $self->override_args;
1528 if(!$self->override) {
1529 return $self->bail_out(1)
1530 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1535 for my $e (@events) {
1536 my $tc = $e->{textcode};
1537 next if $tc eq 'SUCCESS';
1538 if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1539 my $ov = "$tc.override";
1540 $logger->info("circulator: attempting to override event: $ov");
1542 return $self->bail_on_events($self->editor->event)
1543 unless( $self->editor->allowed($ov) );
1545 return $self->bail_out(1);
1551 # --------------------------------------------------------------------------
1552 # If there is an open claimsreturn circ on the requested copy, close the
1553 # circ if overriding, otherwise bail out
1554 # --------------------------------------------------------------------------
1555 sub handle_claims_returned {
1557 my $copy = $self->copy;
1559 my $CR = $self->editor->search_action_circulation(
1561 target_copy => $copy->id,
1562 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1563 checkin_time => undef,
1567 return unless ($CR = $CR->[0]);
1571 # - If the caller has set the override flag, we will check the item in
1572 if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1574 $CR->checkin_time('now');
1575 $CR->checkin_scan_time('now');
1576 $CR->checkin_lib($self->circ_lib);
1577 $CR->checkin_workstation($self->editor->requestor->wsid);
1578 $CR->checkin_staff($self->editor->requestor->id);
1580 $evt = $self->editor->event
1581 unless $self->editor->update_action_circulation($CR);
1584 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1587 $self->bail_on_events($evt) if $evt;
1592 # --------------------------------------------------------------------------
1593 # This performs the checkout
1594 # --------------------------------------------------------------------------
1598 $self->log_me("do_checkout()");
1600 # make sure perms are good if this isn't a renewal
1601 unless( $self->is_renewal ) {
1602 return $self->bail_on_events($self->editor->event)
1603 unless( $self->editor->allowed('COPY_CHECKOUT') );
1606 # verify the permit key
1607 unless( $self->check_permit_key ) {
1608 if( $self->permit_override ) {
1609 return $self->bail_on_events($self->editor->event)
1610 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1612 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1616 # if this is a non-cataloged circ, build the circ and finish
1617 if( $self->is_noncat ) {
1618 $self->checkout_noncat;
1620 OpenILS::Event->new('SUCCESS',
1621 payload => { noncat_circ => $self->circ }));
1625 if( $self->is_precat ) {
1626 $self->make_precat_copy;
1627 return if $self->bail_out;
1629 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1630 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1633 $self->do_copy_checks;
1634 return if $self->bail_out;
1636 $self->run_checkout_scripts();
1637 return if $self->bail_out;
1639 $self->build_checkout_circ_object();
1640 return if $self->bail_out;
1642 my $modify_to_start = $self->booking_adjusted_due_date();
1643 return if $self->bail_out;
1645 $self->apply_modified_due_date($modify_to_start);
1646 return if $self->bail_out;
1648 return $self->bail_on_events($self->editor->event)
1649 unless $self->editor->create_action_circulation($self->circ);
1651 # refresh the circ to force local time zone for now
1652 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1654 if($self->limit_groups) {
1655 $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1658 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1660 return if $self->bail_out;
1662 $self->apply_deposit_fee();
1663 return if $self->bail_out;
1665 $self->handle_checkout_holds();
1666 return if $self->bail_out;
1668 # ------------------------------------------------------------------------------
1669 # Update the patron penalty info in the DB. Run it for permit-overrides
1670 # since the penalties are not updated during the permit phase
1671 # ------------------------------------------------------------------------------
1672 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1674 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1677 if($self->is_renewal) {
1678 # flesh the billing summary for the checked-in circ
1679 $pcirc = $self->editor->retrieve_action_circulation([
1681 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1686 OpenILS::Event->new('SUCCESS',
1688 copy => $U->unflesh_copy($self->copy),
1689 volume => $self->volume,
1690 circ => $self->circ,
1692 holds_fulfilled => $self->fulfilled_holds,
1693 deposit_billing => $self->deposit_billing,
1694 rental_billing => $self->rental_billing,
1695 parent_circ => $pcirc,
1696 patron => ($self->return_patron) ? $self->patron : undef,
1697 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1703 sub apply_deposit_fee {
1705 my $copy = $self->copy;
1707 ($self->is_deposit and not $self->is_deposit_exempt) or
1708 ($self->is_rental and not $self->is_rental_exempt);
1710 return if $self->is_deposit and $self->skip_deposit_fee;
1711 return if $self->is_rental and $self->skip_rental_fee;
1713 my $bill = Fieldmapper::money::billing->new;
1714 my $amount = $copy->deposit_amount;
1718 if($self->is_deposit) {
1719 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1721 $self->deposit_billing($bill);
1723 $billing_type = OILS_BILLING_TYPE_RENTAL;
1725 $self->rental_billing($bill);
1728 $bill->xact($self->circ->id);
1729 $bill->amount($amount);
1730 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1731 $bill->billing_type($billing_type);
1732 $bill->btype($btype);
1733 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1735 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1740 my $copy = $self->copy;
1742 my $stat = $copy->status if ref $copy->status;
1743 my $loc = $copy->location if ref $copy->location;
1744 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1746 $copy->status($stat->id) if $stat;
1747 $copy->location($loc->id) if $loc;
1748 $copy->circ_lib($circ_lib->id) if $circ_lib;
1749 $copy->editor($self->editor->requestor->id);
1750 $copy->edit_date('now');
1751 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1753 return $self->bail_on_events($self->editor->event)
1754 unless $self->editor->update_asset_copy($self->copy);
1756 $copy->status($U->copy_status($copy->status));
1757 $copy->location($loc) if $loc;
1758 $copy->circ_lib($circ_lib) if $circ_lib;
1761 sub update_reservation {
1763 my $reservation = $self->reservation;
1765 my $usr = $reservation->usr;
1766 my $target_rt = $reservation->target_resource_type;
1767 my $target_r = $reservation->target_resource;
1768 my $current_r = $reservation->current_resource;
1770 $reservation->usr($usr->id) if ref $usr;
1771 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1772 $reservation->target_resource($target_r->id) if ref $target_r;
1773 $reservation->current_resource($current_r->id) if ref $current_r;
1775 return $self->bail_on_events($self->editor->event)
1776 unless $self->editor->update_booking_reservation($self->reservation);
1779 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1780 $self->reservation($reservation);
1784 sub bail_on_events {
1785 my( $self, @evts ) = @_;
1786 $self->push_events(@evts);
1790 # ------------------------------------------------------------------------------
1791 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1792 # affects copies that will fulfill holds and CIRC affects all other copies.
1793 # If blocks exists, bail, push Events onto the event pile, and return true.
1794 # ------------------------------------------------------------------------------
1795 sub check_hold_fulfill_blocks {
1798 # With the addition of ignore_proximity in csp, we need to fetch
1799 # the proximity of both the circ_lib and the copy's circ_lib to
1800 # the patron's home_ou.
1801 my ($ou_prox, $copy_prox);
1802 my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1803 $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1804 $ou_prox = -1 unless (defined($ou_prox));
1805 my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1806 if ($copy_ou == $self->circ_lib) {
1807 # Save us the time of an extra query.
1808 $copy_prox = $ou_prox;
1810 $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1811 $copy_prox = -1 unless (defined($copy_prox));
1814 # See if the user has any penalties applied that prevent hold fulfillment
1815 my $pens = $self->editor->json_query({
1816 select => {csp => ['name', 'label']},
1817 from => {ausp => {csp => {}}},
1820 usr => $self->patron->id,
1821 org_unit => $U->get_org_full_path($self->circ_lib),
1823 {stop_date => undef},
1824 {stop_date => {'>' => 'now'}}
1828 block_list => {'like' => '%FULFILL%'},
1830 {ignore_proximity => undef},
1831 {ignore_proximity => {'<' => $ou_prox}},
1832 {ignore_proximity => {'<' => $copy_prox}}
1838 return 0 unless @$pens;
1840 for my $pen (@$pens) {
1841 $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1842 my $event = OpenILS::Event->new($pen->{name});
1843 $event->{desc} = $pen->{label};
1844 $self->push_events($event);
1847 $self->override_events;
1848 return $self->bail_out;
1852 # ------------------------------------------------------------------------------
1853 # When an item is checked out, see if we can fulfill a hold for this patron
1854 # ------------------------------------------------------------------------------
1855 sub handle_checkout_holds {
1857 my $copy = $self->copy;
1858 my $patron = $self->patron;
1860 my $e = $self->editor;
1861 $self->fulfilled_holds([]);
1863 # non-cats can't fulfill a hold
1864 return if $self->is_noncat;
1866 my $hold = $e->search_action_hold_request({
1867 current_copy => $copy->id ,
1868 cancel_time => undef,
1869 fulfillment_time => undef
1872 if($hold and $hold->usr != $patron->id) {
1873 # reset the hold since the copy is now checked out
1875 $logger->info("circulator: un-targeting hold ".$hold->id.
1876 " because copy ".$copy->id." is getting checked out");
1878 $hold->clear_prev_check_time;
1879 $hold->clear_current_copy;
1880 $hold->clear_capture_time;
1881 $hold->clear_shelf_time;
1882 $hold->clear_shelf_expire_time;
1883 $hold->clear_current_shelf_lib;
1885 return $self->bail_on_event($e->event)
1886 unless $e->update_action_hold_request($hold);
1892 $hold = $self->find_related_user_hold($copy, $patron) or return;
1893 $logger->info("circulator: found related hold to fulfill in checkout");
1896 return if $self->check_hold_fulfill_blocks;
1898 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1900 # if the hold was never officially captured, capture it.
1901 $hold->clear_hopeless_date;
1902 $hold->current_copy($copy->id);
1903 $hold->capture_time('now') unless $hold->capture_time;
1904 $hold->fulfillment_time('now');
1905 $hold->fulfillment_staff($e->requestor->id);
1906 $hold->fulfillment_lib($self->circ_lib);
1908 return $self->bail_on_events($e->event)
1909 unless $e->update_action_hold_request($hold);
1911 return $self->fulfilled_holds([$hold->id]);
1915 # ------------------------------------------------------------------------------
1916 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1917 # the patron directly targets the checked out item, see if there is another hold
1918 # for the patron that could be fulfilled by the checked out item. Fulfill the
1919 # oldest hold and only fulfill 1 of them.
1921 # For "another hold":
1923 # First, check for one that the copy matches via hold_copy_map, ensuring that
1924 # *any* hold type that this copy could fill may end up filled.
1926 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1927 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1928 # that are non-requestable to count as capturing those hold types.
1929 # ------------------------------------------------------------------------------
1930 sub find_related_user_hold {
1931 my($self, $copy, $patron) = @_;
1932 my $e = $self->editor;
1934 # holds on precat copies are always copy-level, so this call will
1935 # always return undef. Exit early.
1936 return undef if $self->is_precat;
1938 return undef unless $U->ou_ancestor_setting_value(
1939 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1941 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1943 select => {ahr => ['id']},
1952 fkey => 'current_copy',
1953 type => 'left' # there may be no current_copy
1960 fulfillment_time => undef,
1961 cancel_time => undef,
1963 {expire_time => undef},
1964 {expire_time => {'>' => 'now'}}
1968 target_copy => $self->copy->id
1972 {id => undef}, # left-join copy may be nonexistent
1973 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1977 order_by => {ahr => {request_time => {direction => 'asc'}}},
1981 my $hold_info = $e->json_query($args)->[0];
1982 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1983 return undef if $U->ou_ancestor_setting_value(
1984 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1986 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1988 select => {ahr => ['id']},
1993 fkey => 'current_copy',
1994 type => 'left' # there may be no current_copy
2001 fulfillment_time => undef,
2002 cancel_time => undef,
2004 {expire_time => undef},
2005 {expire_time => {'>' => 'now'}}
2012 target => $self->volume->id
2018 target => $self->title->id
2024 {id => undef}, # left-join copy may be nonexistent
2025 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2029 order_by => {ahr => {request_time => {direction => 'asc'}}},
2033 $hold_info = $e->json_query($args)->[0];
2034 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2039 sub run_checkout_scripts {
2052 my $hard_due_date_name;
2054 $self->run_indb_circ_test();
2055 $duration = $self->circ_matrix_matchpoint->duration_rule;
2056 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2057 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2058 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2060 $duration_name = $duration->name if $duration;
2061 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2064 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2065 return $self->bail_on_events($evt) if ($evt && !$nobail);
2067 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2068 return $self->bail_on_events($evt) if ($evt && !$nobail);
2070 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2071 return $self->bail_on_events($evt) if ($evt && !$nobail);
2073 if($hard_due_date_name) {
2074 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2075 return $self->bail_on_events($evt) if ($evt && !$nobail);
2081 # The item circulates with an unlimited duration
2085 $hard_due_date = undef;
2088 $self->duration_rule($duration);
2089 $self->recurring_fines_rule($recurring);
2090 $self->max_fine_rule($max_fine);
2091 $self->hard_due_date($hard_due_date);
2095 sub build_checkout_circ_object {
2098 my $circ = Fieldmapper::action::circulation->new;
2099 my $duration = $self->duration_rule;
2100 my $max = $self->max_fine_rule;
2101 my $recurring = $self->recurring_fines_rule;
2102 my $hard_due_date = $self->hard_due_date;
2103 my $copy = $self->copy;
2104 my $patron = $self->patron;
2105 my $duration_date_ceiling;
2106 my $duration_date_ceiling_force;
2110 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2111 $duration_date_ceiling = $policy->{duration_date_ceiling};
2112 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2114 my $dname = $duration->name;
2115 my $mname = $max->name;
2116 my $rname = $recurring->name;
2118 if($hard_due_date) {
2119 $hdname = $hard_due_date->name;
2122 $logger->debug("circulator: building circulation ".
2123 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2125 $circ->duration($policy->{duration});
2126 $circ->recurring_fine($policy->{recurring_fine});
2127 $circ->duration_rule($duration->name);
2128 $circ->recurring_fine_rule($recurring->name);
2129 $circ->max_fine_rule($max->name);
2130 $circ->max_fine($policy->{max_fine});
2131 $circ->fine_interval($recurring->recurrence_interval);
2132 $circ->renewal_remaining($duration->max_renewals);
2133 $circ->auto_renewal_remaining($duration->max_auto_renewals);
2134 $circ->grace_period($policy->{grace_period});
2138 $logger->info("circulator: copy found with an unlimited circ duration");
2139 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2140 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2141 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2142 $circ->renewal_remaining(0);
2143 $circ->grace_period(0);
2146 $circ->target_copy( $copy->id );
2147 $circ->usr( $patron->id );
2148 $circ->circ_lib( $self->circ_lib );
2149 $circ->workstation($self->editor->requestor->wsid)
2150 if defined $self->editor->requestor->wsid;
2152 # renewals maintain a link to the parent circulation
2153 $circ->parent_circ($self->parent_circ);
2155 if( $self->is_renewal ) {
2156 $circ->opac_renewal('t') if $self->opac_renewal;
2157 $circ->phone_renewal('t') if $self->phone_renewal;
2158 $circ->desk_renewal('t') if $self->desk_renewal;
2159 $circ->auto_renewal('t') if $self->auto_renewal;
2160 $circ->renewal_remaining($self->renewal_remaining);
2161 $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2162 $circ->circ_staff($self->editor->requestor->id);
2165 # if the user provided an overiding checkout time,
2166 # (e.g. the checkout really happened several hours ago), then
2167 # we apply that here. Does this need a perm??
2168 $circ->xact_start(clean_ISO8601($self->checkout_time))
2169 if $self->checkout_time;
2171 # if a patron is renewing, 'requestor' will be the patron
2172 $circ->circ_staff($self->editor->requestor->id);
2173 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2178 sub do_reservation_pickup {
2181 $self->log_me("do_reservation_pickup()");
2183 $self->reservation->pickup_time('now');
2186 $self->reservation->current_resource &&
2187 $U->is_true($self->reservation->target_resource_type->catalog_item)
2189 # We used to try to set $self->copy and $self->patron here,
2190 # but that should already be done.
2192 $self->run_checkout_scripts(1);
2194 my $duration = $self->duration_rule;
2195 my $max = $self->max_fine_rule;
2196 my $recurring = $self->recurring_fines_rule;
2198 if ($duration && $max && $recurring) {
2199 my $policy = $self->get_circ_policy($duration, $recurring, $max);
2201 my $dname = $duration->name;
2202 my $mname = $max->name;
2203 my $rname = $recurring->name;
2205 $logger->debug("circulator: updating reservation ".
2206 "with duration=$dname, maxfine=$mname, recurring=$rname");
2208 $self->reservation->fine_amount($policy->{recurring_fine});
2209 $self->reservation->max_fine($policy->{max_fine});
2210 $self->reservation->fine_interval($recurring->recurrence_interval);
2213 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2214 $self->update_copy();
2217 $self->reservation->fine_amount(
2218 $self->reservation->target_resource_type->fine_amount
2220 $self->reservation->max_fine(
2221 $self->reservation->target_resource_type->max_fine
2223 $self->reservation->fine_interval(
2224 $self->reservation->target_resource_type->fine_interval
2228 $self->update_reservation();
2231 sub do_reservation_return {
2233 my $request = shift;
2235 $self->log_me("do_reservation_return()");
2237 if (not ref $self->reservation) {
2238 my ($reservation, $evt) =
2239 $U->fetch_booking_reservation($self->reservation);
2240 return $self->bail_on_events($evt) if $evt;
2241 $self->reservation($reservation);
2244 $self->handle_fines(1);
2245 $self->reservation->return_time('now');
2246 $self->update_reservation();
2247 $self->reshelve_copy if $self->copy;
2249 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2250 $self->copy( $self->reservation->current_resource->catalog_item );
2254 sub booking_adjusted_due_date {
2256 my $circ = $self->circ;
2257 my $copy = $self->copy;
2259 return undef unless $self->use_booking;
2263 if( $self->due_date ) {
2265 return $self->bail_on_events($self->editor->event)
2266 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2268 $circ->due_date(clean_ISO8601($self->due_date));
2272 return unless $copy and $circ->due_date;
2275 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2276 if (@$booking_items) {
2277 my $booking_item = $booking_items->[0];
2278 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2280 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2281 my $shorten_circ_setting = $resource_type->elbow_room ||
2282 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2285 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2286 my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2287 resource => $booking_item->id
2288 , search_start => 'now'
2289 , search_end => $circ->due_date
2290 , fields => { cancel_time => undef, return_time => undef }
2292 $booking_ses->disconnect;
2294 throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2295 return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2297 my $dt_parser = DateTime::Format::ISO8601->new;
2298 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($circ->due_date) );
2300 for my $bid (@$bookings) {
2302 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2304 my $booking_start = $dt_parser->parse_datetime( clean_ISO8601($booking->start_time) );
2305 my $booking_end = $dt_parser->parse_datetime( clean_ISO8601($booking->end_time) );
2307 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2308 if ($booking_start < DateTime->now);
2311 if ($U->is_true($stop_circ_setting)) {
2312 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2314 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2315 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2318 # We set the circ duration here only to affect the logic that will
2319 # later (in a DB trigger) mangle the time part of the due date to
2320 # 11:59pm. Having any circ duration that is not a whole number of
2321 # days is enough to prevent the "correction."
2322 my $new_circ_duration = $due_date->epoch - time;
2323 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2324 $circ->duration("$new_circ_duration seconds");
2326 $circ->due_date(clean_ISO8601($due_date->strftime('%FT%T%z')));
2330 return $self->bail_on_events($self->editor->event)
2331 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2337 sub apply_modified_due_date {
2339 my $shift_earlier = shift;
2340 my $circ = $self->circ;
2341 my $copy = $self->copy;
2343 if( $self->due_date ) {
2345 return $self->bail_on_events($self->editor->event)
2346 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2348 $circ->due_date(clean_ISO8601($self->due_date));
2352 # if the due_date lands on a day when the location is closed
2353 return unless $copy and $circ->due_date;
2355 $self->extend_renewal_due_date if $self->is_renewal;
2357 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2359 # due-date overlap should be determined by the location the item
2360 # is checked out from, not the owning or circ lib of the item
2361 my $org = $self->circ_lib;
2363 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2364 " with an item due date of ".$circ->due_date );
2366 my $dateinfo = $U->storagereq(
2367 'open-ils.storage.actor.org_unit.closed_date.overlap',
2368 $org, $circ->due_date );
2371 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2372 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2374 # XXX make the behavior more dynamic
2375 # for now, we just push the due date to after the close date
2376 if ($shift_earlier) {
2377 $circ->due_date($dateinfo->{start});
2379 $circ->due_date($dateinfo->{end});
2385 sub extend_renewal_due_date {
2387 my $circ = $self->circ;
2388 my $matchpoint = $self->circ_matrix_matchpoint;
2390 return unless $U->is_true($matchpoint->renew_extends_due_date);
2392 my $prev_circ = $self->editor->retrieve_action_circulation($self->parent_circ);
2394 my $start_time = DateTime::Format::ISO8601->new
2395 ->parse_datetime(clean_ISO8601($prev_circ->xact_start))->epoch;
2397 my $prev_due_date = DateTime::Format::ISO8601->new
2398 ->parse_datetime(clean_ISO8601($prev_circ->due_date));
2400 my $due_date = DateTime::Format::ISO8601->new
2401 ->parse_datetime(clean_ISO8601($circ->due_date));
2403 my $prev_due_time = $prev_due_date->epoch;
2405 my $now_time = DateTime->now->epoch;
2407 return if $prev_due_time < $now_time; # Renewed circ was overdue.
2409 if (my $interval = $matchpoint->renew_extend_min_interval) {
2411 my $min_duration = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2412 my $checkout_duration = $now_time - $start_time;
2414 if ($checkout_duration < $min_duration) {
2415 # Renewal occurred too early in the cycle to result in an
2416 # extension of the due date on the renewal.
2418 # If the new due date falls before the due date of
2419 # the previous circulation, use the due date of the prev.
2420 # circ so the patron does not lose time.
2421 my $due = $due_date < $prev_due_date ? $prev_due_date : $due_date;
2422 $circ->due_date($due->strftime('%FT%T%z'));
2428 # Item was checked out long enough during the previous circulation
2429 # to consider extending the due date of the renewal to cover the gap.
2431 # Amount of the previous duration that was left unused.
2432 my $remaining_duration = $prev_due_time - $now_time;
2434 $due_date->add(seconds => $remaining_duration);
2436 # If the calculated due date falls before the due date of the previous
2437 # circulation, use the due date of the prev. circ so the patron does
2439 my $due = $due_date < $prev_due_date ? $prev_due_date : $due_date;
2441 $logger->info("circulator: renewal due date extension landed on due date: $due");
2443 $circ->due_date($due->strftime('%FT%T%z'));
2447 sub create_due_date {
2448 my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2450 # Look up circulating library's TZ, or else use client TZ, falling
2452 my $tz = $U->ou_ancestor_setting_value(
2458 my $due_date = $start_time ?
2459 DateTime::Format::ISO8601
2461 ->parse_datetime(clean_ISO8601($start_time))
2462 ->set_time_zone($tz) :
2463 DateTime->now(time_zone => $tz);
2465 # add the circ duration
2466 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($duration, $due_date));
2469 my $cdate = DateTime::Format::ISO8601
2471 ->parse_datetime(clean_ISO8601($date_ceiling))
2472 ->set_time_zone($tz);
2474 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2475 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2480 # return ISO8601 time with timezone
2481 return $due_date->strftime('%FT%T%z');
2486 sub make_precat_copy {
2488 my $copy = $self->copy;
2489 return $self->bail_on_events(OpenILS::Event->new('PERM_FAILURE'))
2490 unless $self->editor->allowed('CREATE_PRECAT') || $self->is_renewal;
2493 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2495 $copy->editor($self->editor->requestor->id);
2496 $copy->edit_date('now');
2497 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2498 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2499 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2500 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2501 $self->update_copy();
2505 $logger->info("circulator: Creating a new precataloged ".
2506 "copy in checkout with barcode " . $self->copy_barcode);
2508 $copy = Fieldmapper::asset::copy->new;
2509 $copy->circ_lib($self->circ_lib);
2510 $copy->creator($self->editor->requestor->id);
2511 $copy->editor($self->editor->requestor->id);
2512 $copy->barcode($self->copy_barcode);
2513 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2514 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2515 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2517 $copy->dummy_title($self->dummy_title || "");
2518 $copy->dummy_author($self->dummy_author || "");
2519 $copy->dummy_isbn($self->dummy_isbn || "");
2520 $copy->circ_modifier($self->circ_modifier);
2523 # See if we need to override the circ_lib for the copy with a configured circ_lib
2524 # Setting is shortname of the org unit
2525 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2526 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2528 if($precat_circ_lib) {
2529 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2532 $self->bail_on_events($self->editor->event);
2536 $copy->circ_lib($org->id);
2540 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2542 $self->push_events($self->editor->event);
2548 sub checkout_noncat {
2554 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2555 my $count = $self->noncat_count || 1;
2556 my $cotime = clean_ISO8601($self->checkout_time) || "";
2558 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2562 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2563 $self->editor->requestor->id,
2571 $self->push_events($evt);
2579 # if an item is in transit but the status doesn't agree, then we need to fix things.
2580 # The next two subs will hopefully do that
2581 sub fix_broken_transit_status {
2584 # Capture the transit so we don't have to fetch it again later during checkin
2585 # This used to live in sub check_transit_checkin_interval and later again in
2588 $self->editor->search_action_transit_copy(
2589 {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2593 if ($self->transit && $U->copy_status($self->copy->status)->id != OILS_COPY_STATUS_IN_TRANSIT) {
2594 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2595 " that is in-transit but without the In Transit status... fixing");
2596 $self->copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2597 # FIXME - do we want to make this permanent if the checkin bails?
2602 sub cancel_transit_if_circ_exists {
2604 if ($self->circ && $self->transit) {
2605 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2606 " that is in-transit AND circulating... aborting the transit");
2607 my $circ_ses = create OpenSRF::AppSession("open-ils.circ");
2608 my $result = $circ_ses->request(
2609 "open-ils.circ.transit.abort",
2610 $self->editor->authtoken,
2611 { 'transitid' => $self->transit->id }
2613 $logger->warn("circulator: transit abort result: ".$result);
2614 $circ_ses->disconnect;
2615 $self->transit(undef);
2619 # If a copy goes into transit and is then checked in before the transit checkin
2620 # interval has expired, push an event onto the overridable events list.
2621 sub check_transit_checkin_interval {
2624 # only concerned with in-transit items
2625 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2627 # no interval, no problem
2628 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2629 return unless $interval;
2631 # transit from X to X for whatever reason has no min interval
2632 return if $self->transit->source == $self->transit->dest;
2634 my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2635 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2636 my $horizon = $t_start->add(seconds => $seconds);
2638 # See if we are still within the transit checkin forbidden range
2639 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2640 if $horizon > DateTime->now;
2643 # Retarget local holds at checkin
2644 sub checkin_retarget {
2646 return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2647 return unless $self->is_checkin; # Renewals need not be checked
2648 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2649 return if $self->is_precat; # No holds for precats
2650 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2651 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2652 my $status = $U->copy_status($self->copy->status);
2653 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2654 # Specifically target items that are likely new (by status ID)
2655 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2656 my $location = $self->copy->location;
2657 if(!ref($location)) {
2658 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2659 $self->copy->location($location);
2661 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2663 # Fetch holds for the bib
2664 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2665 $self->editor->authtoken,
2668 capture_time => undef, # No touching captured holds
2669 frozen => 'f', # Don't bother with frozen holds
2670 pickup_lib => $self->circ_lib # Only holds actually here
2673 # Error? Skip the step.
2674 return if exists $result->{"ilsevent"};
2678 foreach my $holdlist (keys %{$result}) {
2679 push @$holds, @{$result->{$holdlist}};
2682 return if scalar(@$holds) == 0; # No holds, no retargeting
2684 # Check for parts on this copy
2685 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2686 my %parts_hash = ();
2687 %parts_hash = map {$_->part, 1} @$parts if @$parts;
2689 # Loop over holds in request-ish order
2690 # Stage 1: Get them into request-ish order
2691 # Also grab type and target for skipping low hanging ones
2692 $result = $self->editor->json_query({
2693 "select" => { "ahr" => ["id", "hold_type", "target"] },
2694 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2695 "where" => { "id" => $holds },
2697 { "class" => "pgt", "field" => "hold_priority"},
2698 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2699 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2700 { "class" => "ahr", "field" => "request_time"}
2705 if (ref $result eq "ARRAY" and scalar @$result) {
2706 foreach (@{$result}) {
2707 # Copy level, but not this copy?
2708 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2709 and $_->{target} != $self->copy->id);
2710 # Volume level, but not this volume?
2711 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2712 if(@$parts) { # We have parts?
2714 next if ($_->{hold_type} eq 'T');
2715 # Skip part holds for parts not on this copy
2716 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2718 # No parts, no part holds
2719 next if ($_->{hold_type} eq 'P');
2721 # So much for easy stuff, attempt a retarget!
2722 my $tresult = $U->simplereq(
2723 'open-ils.hold-targeter',
2724 'open-ils.hold-targeter.target',
2725 {hold => $_->{id}, find_copy => $self->copy->id}
2727 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2728 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2736 $self->log_me("do_checkin()");
2738 return $self->bail_on_events(
2739 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2742 # Never capture a deleted copy for a hold.
2743 $self->capture('nocapture') if $U->is_true($self->copy->deleted);
2745 $self->fix_broken_transit_status; # if applicable
2746 $self->check_transit_checkin_interval;
2747 $self->checkin_retarget;
2749 # the renew code and mk_env should have already found our circulation object
2750 unless( $self->circ ) {
2752 my $circs = $self->editor->search_action_circulation(
2753 { target_copy => $self->copy->id, checkin_time => undef });
2755 $self->circ($$circs[0]);
2757 # for now, just warn if there are multiple open circs on a copy
2758 $logger->warn("circulator: we have ".scalar(@$circs).
2759 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2761 $self->cancel_transit_if_circ_exists; # if applicable
2763 my $stat = $U->copy_status($self->copy->status)->id;
2765 # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2766 # differently if they are already paid for. We need to check for this
2767 # early since overdue generation is potentially affected.
2768 my $dont_change_lost_zero = 0;
2769 if ($stat == OILS_COPY_STATUS_LOST
2770 || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2771 || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2773 # LOST fine settings are controlled by the copy's circ lib, not the the
2775 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2776 $self->copy->circ_lib->id : $self->copy->circ_lib;
2777 $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2778 $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2779 $self->editor) || 0;
2781 # Don't assume there's always a circ based on copy status
2782 if ($dont_change_lost_zero && $self->circ) {
2783 my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2784 $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2787 $self->dont_change_lost_zero($dont_change_lost_zero);
2790 # Check if the copy can float to here. We need this for inventory
2791 # and to see if the copy needs to transit or stay here later.
2793 if ($self->copy->floating) {
2794 my $res = $self->editor->json_query(
2797 'evergreen.can_float',
2798 $self->copy->floating->id,
2799 $self->copy->circ_lib,
2804 $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res;
2807 # Do copy inventory if necessary.
2808 if ($self->do_inventory_update && ($self->circ_lib == $self->copy->circ_lib || $can_float)) {
2809 my $aci = Fieldmapper::asset::copy_inventory->new();
2810 $aci->inventory_date('now');
2811 $aci->inventory_workstation($self->editor->requestor->wsid);
2812 $aci->copy($self->copy->id());
2813 $self->editor->create_asset_copy_inventory($aci);
2814 $self->checkin_changed(1);
2817 if( $self->checkin_check_holds_shelf() ) {
2818 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2819 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2820 if($self->fake_hold_dest) {
2821 $self->hold->pickup_lib($self->circ_lib);
2823 $self->checkin_flesh_events;
2827 unless( $self->is_renewal ) {
2828 return $self->bail_on_events($self->editor->event)
2829 unless $self->editor->allowed('COPY_CHECKIN');
2832 $self->push_events($self->check_copy_alert());
2833 $self->push_events($self->check_checkin_copy_status());
2835 # if the circ is marked as 'claims returned', add the event to the list
2836 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2837 if ($self->circ and $self->circ->stop_fines
2838 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2840 $self->check_circ_deposit();
2842 # handle the overridable events
2843 $self->override_events unless $self->is_renewal;
2844 return if $self->bail_out;
2847 $self->checkin_handle_circ_start;
2848 return if $self->bail_out;
2850 if (!$dont_change_lost_zero) {
2851 # if this circ is LOST and we are configured to generate overdue
2852 # fines for lost items on checkin (to fill the gap between mark
2853 # lost time and when the fines would have naturally stopped), then
2854 # stop_fines is no longer valid and should be cleared.
2856 # stop_fines will be set again during the handle_fines() stage.
2857 # XXX should this setting come from the copy circ lib (like other
2858 # LOST settings), instead of the circulation circ lib?
2859 if ($stat == OILS_COPY_STATUS_LOST) {
2860 $self->circ->clear_stop_fines if
2861 $U->ou_ancestor_setting_value(
2863 OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2868 # Set stop_fines when claimed never checked out
2869 $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2871 # handle fines for this circ, including overdue gen if needed
2872 $self->handle_fines;
2875 # Void any item deposits if the library wants to
2876 $self->check_circ_deposit(1);
2878 $self->checkin_handle_circ_finish;
2879 return if $self->bail_out;
2880 $self->checkin_changed(1);
2882 } elsif( $self->transit ) {
2883 my $hold_transit = $self->process_received_transit;
2884 $self->checkin_changed(1);
2886 if( $self->bail_out ) {
2887 $self->checkin_flesh_events;
2891 if( my $e = $self->check_checkin_copy_status() ) {
2892 # If the original copy status is special, alert the caller
2893 my $ev = $self->events;
2894 $self->events([$e]);
2895 $self->override_events;
2896 return if $self->bail_out;
2900 if( $hold_transit or
2901 $U->copy_status($self->copy->status)->id
2902 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2905 if( $hold_transit ) {
2906 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2908 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2913 if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2915 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2916 $self->reshelve_copy(1);
2917 $self->cancelled_hold_transit(1);
2918 $self->notify_hold(0); # don't notify for cancelled holds
2919 $self->fake_hold_dest(0);
2920 return if $self->bail_out;
2922 } elsif ($hold and $hold->hold_type eq 'R') {
2924 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2925 $self->notify_hold(0); # No need to notify
2926 $self->fake_hold_dest(0);
2927 $self->noop(1); # Don't try and capture for other holds/transits now
2928 $self->update_copy();
2929 $hold->fulfillment_time('now');
2930 $self->bail_on_events($self->editor->event)
2931 unless $self->editor->update_action_hold_request($hold);
2935 # hold transited to correct location
2936 if($self->fake_hold_dest) {
2937 $hold->pickup_lib($self->circ_lib);
2939 $self->checkin_flesh_events;
2944 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2946 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2947 " that is in-transit, but there is no transit.. repairing");
2948 $self->reshelve_copy(1);
2949 return if $self->bail_out;
2952 if( $self->is_renewal ) {
2953 $self->finish_fines_and_voiding;
2954 return if $self->bail_out;
2955 $self->push_events(OpenILS::Event->new('SUCCESS'));
2959 # ------------------------------------------------------------------------------
2960 # Circulations and transits are now closed where necessary. Now go on to see if
2961 # this copy can fulfill a hold or needs to be routed to a different location
2962 # ------------------------------------------------------------------------------
2964 my $needed_for_something = 0; # formerly "needed_for_hold"
2966 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2968 if (!$self->remote_hold) {
2969 if ($self->use_booking) {
2970 my $potential_hold = $self->hold_capture_is_possible;
2971 my $potential_reservation = $self->reservation_capture_is_possible;
2973 if ($potential_hold and $potential_reservation) {
2974 $logger->info("circulator: item could fulfill either hold or reservation");
2975 $self->push_events(new OpenILS::Event(
2976 "HOLD_RESERVATION_CONFLICT",
2977 "hold" => $potential_hold,
2978 "reservation" => $potential_reservation
2980 return if $self->bail_out;
2981 } elsif ($potential_hold) {
2982 $needed_for_something =
2983 $self->attempt_checkin_hold_capture;
2984 } elsif ($potential_reservation) {
2985 $needed_for_something =
2986 $self->attempt_checkin_reservation_capture;
2989 $needed_for_something = $self->attempt_checkin_hold_capture;
2992 return if $self->bail_out;
2994 unless($needed_for_something) {
2995 my $circ_lib = (ref $self->copy->circ_lib) ?
2996 $self->copy->circ_lib->id : $self->copy->circ_lib;
2998 if( $self->remote_hold ) {
2999 $circ_lib = $self->remote_hold->pickup_lib;
3000 $logger->warn("circulator: Copy ".$self->copy->barcode.
3001 " is on a remote hold's shelf, sending to $circ_lib");
3004 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
3006 my $suppress_transit = 0;
3008 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
3009 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
3010 if($suppress_transit_source && $suppress_transit_source->{value}) {
3011 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
3012 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
3013 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
3014 $suppress_transit = 1;
3019 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
3020 # copy is where it needs to be, either for hold or reshelving
3022 $self->checkin_handle_precat();
3023 return if $self->bail_out;
3026 # copy needs to transit "home", or stick here if it's a floating copy
3027 if ($can_float && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # Yep, floating, stick here
3028 $self->checkin_changed(1);
3029 $self->copy->circ_lib( $self->circ_lib );
3032 my $bc = $self->copy->barcode;
3033 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
3034 $self->checkin_build_copy_transit($circ_lib);
3035 return if $self->bail_out;
3036 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
3040 } else { # no-op checkin
3041 # XXX floating items still stick where they are even with no-op checkin?
3042 if ($self->copy->floating && $can_float) {
3043 $self->checkin_changed(1);
3044 $self->copy->circ_lib( $self->circ_lib );
3049 if($self->claims_never_checked_out and
3050 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
3052 # the item was not supposed to be checked out to the user and should now be marked as missing
3053 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
3054 $self->copy->status($next_status);
3058 $self->reshelve_copy unless $needed_for_something;
3061 return if $self->bail_out;
3063 unless($self->checkin_changed) {
3065 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
3066 my $stat = $U->copy_status($self->copy->status)->id;
3068 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
3069 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
3070 $self->bail_out(1); # no need to commit anything
3074 $self->push_events(OpenILS::Event->new('SUCCESS'))
3075 unless @{$self->events};
3078 $self->finish_fines_and_voiding;
3080 OpenILS::Utils::Penalty->calculate_penalties(
3081 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3083 $self->checkin_flesh_events;
3087 sub finish_fines_and_voiding {
3089 return unless $self->circ;
3091 return unless $self->backdate or $self->void_overdues;
3093 # void overdues after fine generation to prevent concurrent DB access to overdue billings
3094 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3096 my $evt = $CC->void_or_zero_overdues(
3097 $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3099 return $self->bail_on_events($evt) if $evt;
3101 # Make sure the circ is open or closed as necessary.
3102 $evt = $U->check_open_xact($self->editor, $self->circ->id);
3103 return $self->bail_on_events($evt) if $evt;
3109 # if a deposit was paid for this item, push the event
3110 # if called with a truthy param perform the void, depending on settings
3111 sub check_circ_deposit {
3115 return unless $self->circ;
3117 my $deposit = $self->editor->search_money_billing(
3119 xact => $self->circ->id,
3121 }, {idlist => 1})->[0];
3123 return unless $deposit;
3126 my $void_on_checkin = $U->ou_ancestor_setting_value(
3127 $self->circ_lib,OILS_SETTING_VOID_ITEM_DEPOSIT_ON_CHECKIN,$self->editor);
3128 if ( $void_on_checkin ) {
3129 my $evt = $CC->void_bills($self->editor,[$deposit], "DEPOSIT ITEM RETURNED");
3130 return $evt if $evt;
3132 } else { # if void is unset this is just a check, notify that there was a deposit billing
3133 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_PAID', payload => $deposit));
3139 my $force = $self->force || shift;
3140 my $copy = $self->copy;
3142 my $stat = $U->copy_status($copy->status)->id;
3144 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3147 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3148 $stat != OILS_COPY_STATUS_CATALOGING and
3149 $stat != OILS_COPY_STATUS_IN_TRANSIT and
3150 $stat != $next_status )) {
3152 $copy->status( $next_status );
3154 $self->checkin_changed(1);
3159 # Returns true if the item is at the current location
3160 # because it was transited there for a hold and the
3161 # hold has not been fulfilled
3162 sub checkin_check_holds_shelf {
3164 return 0 unless $self->copy;
3167 $U->copy_status($self->copy->status)->id ==
3168 OILS_COPY_STATUS_ON_HOLDS_SHELF;
3170 # Attempt to clear shelf expired holds for this copy
3171 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3172 if($self->clear_expired);
3174 # find the hold that put us on the holds shelf
3175 my $holds = $self->editor->search_action_hold_request(
3177 current_copy => $self->copy->id,
3178 capture_time => { '!=' => undef },
3179 fulfillment_time => undef,
3180 cancel_time => undef,
3185 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3186 $self->reshelve_copy(1);
3190 my $hold = $$holds[0];
3192 $logger->info("circulator: we found a captured, un-fulfilled hold [".
3193 $hold->id. "] for copy ".$self->copy->barcode);
3195 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3196 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3197 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3198 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3199 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3200 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3201 $self->fake_hold_dest(1);
3207 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3208 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3212 $logger->info("circulator: hold is not for here..");
3213 $self->remote_hold($hold);
3218 sub checkin_handle_precat {
3220 my $copy = $self->copy;
3222 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3223 $copy->status(OILS_COPY_STATUS_CATALOGING);
3224 $self->update_copy();
3225 $self->checkin_changed(1);
3226 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3231 sub checkin_build_copy_transit {
3234 my $copy = $self->copy;
3235 my $transit = Fieldmapper::action::transit_copy->new;
3237 # if we are transiting an item to the shelf shelf, it's a hold transit
3238 if (my $hold = $self->remote_hold) {
3239 $transit = Fieldmapper::action::hold_transit_copy->new;
3240 $transit->hold($hold->id);
3242 # the item is going into transit, remove any shelf-iness
3243 if ($hold->current_shelf_lib or $hold->shelf_time) {
3244 $hold->clear_current_shelf_lib;
3245 $hold->clear_shelf_time;
3246 return $self->bail_on_events($self->editor->event)
3247 unless $self->editor->update_action_hold_request($hold);
3251 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3252 $logger->info("circulator: transiting copy to $dest");
3254 $transit->source($self->circ_lib);
3255 $transit->dest($dest);
3256 $transit->target_copy($copy->id);
3257 $transit->source_send_time('now');
3258 $transit->copy_status( $U->copy_status($copy->status)->id );
3260 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3262 if ($self->remote_hold) {
3263 return $self->bail_on_events($self->editor->event)
3264 unless $self->editor->create_action_hold_transit_copy($transit);
3266 return $self->bail_on_events($self->editor->event)
3267 unless $self->editor->create_action_transit_copy($transit);
3270 # ensure the transit is returned to the caller
3271 $self->transit($transit);
3273 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3275 $self->checkin_changed(1);
3279 sub hold_capture_is_possible {
3281 my $copy = $self->copy;
3283 # we've been explicitly told not to capture any holds
3284 return 0 if $self->capture eq 'nocapture';
3286 # See if this copy can fulfill any holds
3287 my $hold = $holdcode->find_nearest_permitted_hold(
3288 $self->editor, $copy, $self->editor->requestor, 1 # check_only
3290 return undef if ref $hold eq "HASH" and
3291 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3295 sub reservation_capture_is_possible {
3297 my $copy = $self->copy;
3299 # we've been explicitly told not to capture any holds
3300 return 0 if $self->capture eq 'nocapture';
3302 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3303 my $resv = $booking_ses->request(
3304 "open-ils.booking.reservations.could_capture",
3305 $self->editor->authtoken, $copy->barcode
3307 $booking_ses->disconnect;
3308 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3309 $self->push_events($resv);
3315 # returns true if the item was used (or may potentially be used
3316 # in subsequent calls) to capture a hold.
3317 sub attempt_checkin_hold_capture {
3319 my $copy = $self->copy;
3321 # we've been explicitly told not to capture any holds
3322 return 0 if $self->capture eq 'nocapture';
3324 # See if this copy can fulfill any holds
3325 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3326 $self->editor, $copy, $self->editor->requestor );
3329 $logger->debug("circulator: no potential permitted".
3330 "holds found for copy ".$copy->barcode);
3334 if($self->capture ne 'capture') {
3335 # see if this item is in a hold-capture-delay location
3336 my $location = $self->copy->location;
3337 if(!ref($location)) {
3338 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3339 $self->copy->location($location);
3341 if($U->is_true($location->hold_verify)) {
3342 $self->bail_on_events(
3343 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3348 $self->retarget($retarget);
3350 my $suppress_transit = 0;
3351 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3352 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3353 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3354 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3355 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3356 $suppress_transit = 1;
3357 $hold->pickup_lib($self->circ_lib);
3362 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3364 $hold->clear_hopeless_date;
3365 $hold->current_copy($copy->id);
3366 $hold->capture_time('now');
3367 $self->put_hold_on_shelf($hold)
3368 if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3370 # prevent DB errors caused by fetching
3371 # holds from storage, and updating through cstore
3372 $hold->clear_fulfillment_time;
3373 $hold->clear_fulfillment_staff;
3374 $hold->clear_fulfillment_lib;
3375 $hold->clear_expire_time;
3376 $hold->clear_cancel_time;
3377 $hold->clear_prev_check_time unless $hold->prev_check_time;
3379 $self->bail_on_events($self->editor->event)
3380 unless $self->editor->update_action_hold_request($hold);
3382 $self->checkin_changed(1);
3384 return 0 if $self->bail_out;
3386 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3388 if ($hold->hold_type eq 'R') {
3389 $copy->status(OILS_COPY_STATUS_CATALOGING);
3390 $hold->fulfillment_time('now');
3391 $self->noop(1); # Block other transit/hold checks
3392 $self->bail_on_events($self->editor->event)
3393 unless $self->editor->update_action_hold_request($hold);
3395 # This hold was captured in the correct location
3396 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3397 $self->push_events(OpenILS::Event->new('SUCCESS'));
3399 #$self->do_hold_notify($hold->id);
3400 $self->notify_hold($hold->id);
3405 # Hold needs to be picked up elsewhere. Build a hold
3406 # transit and route the item.
3407 $self->checkin_build_hold_transit();
3408 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3409 return 0 if $self->bail_out;
3410 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3413 # make sure we save the copy status
3415 return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3419 sub attempt_checkin_reservation_capture {
3421 my $copy = $self->copy;
3423 # we've been explicitly told not to capture any holds
3424 return 0 if $self->capture eq 'nocapture';
3426 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3427 my $evt = $booking_ses->request(
3428 "open-ils.booking.resources.capture_for_reservation",
3429 $self->editor->authtoken,
3431 1 # don't update copy - we probably have it locked
3433 $booking_ses->disconnect;
3435 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3437 "open-ils.booking.resources.capture_for_reservation " .
3438 "didn't return an event!"
3442 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3443 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3445 # not-transferable is an error event we'll pass on the user
3446 $logger->warn("reservation capture attempted against non-transferable item");
3447 $self->push_events($evt);
3449 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3450 # Re-retrieve copy as reservation capture may have changed
3451 # its status and whatnot.
3453 "circulator: booking capture win on copy " . $self->copy->id
3455 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3457 "circulator: changing copy " . $self->copy->id .
3458 "'s status from " . $self->copy->status . " to " .
3461 $self->copy->status($new_copy_status);
3464 $self->reservation($evt->{"payload"}->{"reservation"});
3466 if (exists $evt->{"payload"}->{"transit"}) {
3470 "org" => $evt->{"payload"}->{"transit"}->dest
3474 $self->checkin_changed(1);
3478 # other results are treated as "nothing to capture"
3482 sub do_hold_notify {
3483 my( $self, $holdid ) = @_;
3485 my $e = new_editor(xact => 1);
3486 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3488 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3489 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3491 $logger->info("circulator: running delayed hold notify process");
3493 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3494 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3496 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3497 hold_id => $holdid, requestor => $self->editor->requestor);
3499 $logger->debug("circulator: built hold notifier");
3501 if(!$notifier->event) {
3503 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3505 my $stat = $notifier->send_email_notify;
3506 if( $stat == '1' ) {
3507 $logger->info("circulator: hold notify succeeded for hold $holdid");
3511 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3514 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3518 sub retarget_holds {
3520 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3521 my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3522 $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3523 # no reason to wait for the return value
3527 sub checkin_build_hold_transit {
3530 my $copy = $self->copy;
3531 my $hold = $self->hold;
3532 my $trans = Fieldmapper::action::hold_transit_copy->new;
3534 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3536 $trans->hold($hold->id);
3537 $trans->source($self->circ_lib);
3538 $trans->dest($hold->pickup_lib);
3539 $trans->source_send_time("now");
3540 $trans->target_copy($copy->id);
3542 # when the copy gets to its destination, it will recover
3543 # this status - put it onto the holds shelf
3544 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3546 return $self->bail_on_events($self->editor->event)
3547 unless $self->editor->create_action_hold_transit_copy($trans);
3552 sub process_received_transit {
3554 my $copy = $self->copy;
3555 my $copyid = $self->copy->id;
3557 my $status_name = $U->copy_status($copy->status)->name;
3558 $logger->debug("circulator: attempting transit receive on ".
3559 "copy $copyid. Copy status is $status_name");
3561 my $transit = $self->transit;
3563 # Check if we are in a transit suppress range
3564 my $suppress_transit = 0;
3565 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3566 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3567 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3568 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3569 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3570 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3571 $suppress_transit = 1;
3572 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3576 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3577 # - this item is in-transit to a different location
3578 # - Or we are capturing holds as transits, so why create a new transit?
3580 my $tid = $transit->id;
3581 my $loc = $self->circ_lib;
3582 my $dest = $transit->dest;
3584 $logger->info("circulator: Fowarding transit on copy which is destined ".
3585 "for a different location. transit=$tid, copy=$copyid, current ".
3586 "location=$loc, destination location=$dest");
3588 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3590 # grab the associated hold object if available
3591 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3592 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3594 return $self->bail_on_events($evt);
3597 # The transit is received, set the receive time
3598 $transit->dest_recv_time('now');
3599 $self->bail_on_events($self->editor->event)
3600 unless $self->editor->update_action_transit_copy($transit);
3602 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3604 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3605 $copy->status( $transit->copy_status );
3606 $self->update_copy();
3607 return if $self->bail_out;
3611 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3614 # hold has arrived at destination, set shelf time
3615 $self->put_hold_on_shelf($hold);
3616 $self->bail_on_events($self->editor->event)
3617 unless $self->editor->update_action_hold_request($hold);
3618 return if $self->bail_out;
3620 $self->notify_hold($hold_transit->hold);
3623 $hold_transit = undef;
3624 $self->cancelled_hold_transit(1);
3625 $self->reshelve_copy(1);
3626 $self->fake_hold_dest(0);
3631 OpenILS::Event->new(
3634 payload => { transit => $transit, holdtransit => $hold_transit } ));
3636 return $hold_transit;
3640 # ------------------------------------------------------------------
3641 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3642 # ------------------------------------------------------------------
3643 sub put_hold_on_shelf {
3644 my($self, $hold) = @_;
3645 $hold->shelf_time('now');
3646 $hold->current_shelf_lib($self->circ_lib);
3647 $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3653 my $reservation = shift;
3654 my $dt_parser = DateTime::Format::ISO8601->new;
3656 my $obj = $reservation ? $self->reservation : $self->circ;
3658 my $lost_bill_opts = $self->lost_bill_options;
3659 my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3660 # first, restore any voided overdues for lost, if needed
3661 if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3662 my $restore_od = $U->ou_ancestor_setting_value(
3663 $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3664 $self->editor) || 0;
3665 $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3669 # next, handle normal overdue generation and apply stop_fines
3670 # XXX reservations don't have stop_fines
3671 # TODO revisit booking_reservation re: stop_fines support
3672 if ($reservation or !$obj->stop_fines) {
3675 # This is a crude check for whether we are in a grace period. The code
3676 # in generate_fines() does a more thorough job, so this exists solely
3677 # as a small optimization, and might be better off removed.
3679 # If we have a grace period
3680 if($obj->can('grace_period')) {
3681 # Parse out the due date
3682 my $due_date = $dt_parser->parse_datetime( clean_ISO8601($obj->due_date) );
3683 # Add the grace period to the due date
3684 $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($obj->grace_period));
3685 # Don't generate fines on circs still in grace period
3686 $skip_for_grace = $due_date > DateTime->now;
3688 $CC->generate_fines({circs => [$obj], editor => $self->editor})
3689 unless $skip_for_grace;
3691 if (!$reservation and !$obj->stop_fines) {
3692 $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3693 $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3694 $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3695 $obj->stop_fines_time('now');
3696 $obj->stop_fines_time($self->backdate) if $self->backdate;
3697 $self->editor->update_action_circulation($obj);
3701 # finally, handle voiding of lost item and processing fees
3702 if ($self->needs_lost_bill_handling) {
3703 my $void_cost = $U->ou_ancestor_setting_value(
3704 $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3705 $self->editor) || 0;
3706 my $void_proc_fee = $U->ou_ancestor_setting_value(
3707 $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3708 $self->editor) || 0;
3709 $self->checkin_handle_lost_or_lo_now_found(
3710 $lost_bill_opts->{void_cost_btype},
3711 $lost_bill_opts->{is_longoverdue}) if $void_cost;
3712 $self->checkin_handle_lost_or_lo_now_found(
3713 $lost_bill_opts->{void_fee_btype},
3714 $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3720 sub checkin_handle_circ_start {
3722 my $circ = $self->circ;
3723 my $copy = $self->copy;
3727 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3729 # backdate the circ if necessary
3730 if($self->backdate) {
3731 my $evt = $self->checkin_handle_backdate;
3732 return $self->bail_on_events($evt) if $evt;
3735 # Set the checkin vars since we have the item
3736 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3738 # capture the true scan time for back-dated checkins
3739 $circ->checkin_scan_time('now');
3741 $circ->checkin_staff($self->editor->requestor->id);
3742 $circ->checkin_lib($self->circ_lib);
3743 $circ->checkin_workstation($self->editor->requestor->wsid);
3745 my $circ_lib = (ref $self->copy->circ_lib) ?
3746 $self->copy->circ_lib->id : $self->copy->circ_lib;
3747 my $stat = $U->copy_status($self->copy->status)->id;
3749 if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3750 # we will now handle lost fines, but the copy will retain its 'lost'
3751 # status if it needs to transit home unless lost_immediately_available
3754 # if we decide to also delay fine handling until the item arrives home,
3755 # we will need to call lost fine handling code both when checking items
3756 # in and also when receiving transits
3757 $self->checkin_handle_lost($circ_lib);
3758 } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3759 # same process as above.
3760 $self->checkin_handle_long_overdue($circ_lib);
3761 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3762 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3764 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3765 $self->copy->status($U->copy_status($next_status));
3772 sub checkin_handle_circ_finish {
3774 my $e = $self->editor;
3775 my $circ = $self->circ;
3777 # Do one last check before the final circulation update to see
3778 # if the xact_finish value should be set or not.
3780 # The underlying money.billable_xact may have been updated to
3781 # reflect a change in xact_finish during checkin bills handling,
3782 # however we can't simply refresh the circulation from the DB,
3783 # because other changes may be pending. Instead, reproduce the
3784 # xact_finish check here. It won't hurt to do it again.
3786 my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3787 if ($sum) { # is this test still needed?
3789 my $balance = $sum->balance_owed;
3791 if ($balance == 0) {
3792 $circ->xact_finish('now');
3794 $circ->clear_xact_finish;
3797 $logger->info("circulator: $balance is owed on this circulation");
3800 return $self->bail_on_events($e->event)
3801 unless $e->update_action_circulation($circ);
3806 # ------------------------------------------------------------------
3807 # See if we need to void billings, etc. for lost checkin
3808 # ------------------------------------------------------------------
3809 sub checkin_handle_lost {
3811 my $circ_lib = shift;
3813 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3814 OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3816 $self->lost_bill_options({
3817 circ_lib => $circ_lib,
3818 ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3819 ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3820 ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3821 void_cost_btype => 3,
3825 return $self->checkin_handle_lost_or_longoverdue(
3826 circ_lib => $circ_lib,
3827 max_return => $max_return,
3828 ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3829 ous_use_last_activity => undef # not supported for LOST checkin
3833 # ------------------------------------------------------------------
3834 # See if we need to void billings, etc. for long-overdue checkin
3835 # note: not using constants below since they serve little purpose
3836 # for single-use strings that are descriptive in their own right
3837 # and mostly just complicate debugging.
3838 # ------------------------------------------------------------------
3839 sub checkin_handle_long_overdue {
3841 my $circ_lib = shift;
3843 $logger->info("circulator: processing long-overdue checkin...");
3845 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3846 'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3848 $self->lost_bill_options({
3849 circ_lib => $circ_lib,
3850 ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3851 ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3852 is_longoverdue => 1,
3853 ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3854 void_cost_btype => 10,
3855 void_fee_btype => 11
3858 return $self->checkin_handle_lost_or_longoverdue(
3859 circ_lib => $circ_lib,
3860 max_return => $max_return,
3861 ous_immediately_available => 'circ.longoverdue_immediately_available',
3862 ous_use_last_activity =>
3863 'circ.longoverdue.use_last_activity_date_on_return'
3867 # last billing activity is last payment time, last billing time, or the
3868 # circ due date. If the relevant "use last activity" org unit setting is
3869 # false/unset, then last billing activity is always the due date.
3870 sub get_circ_last_billing_activity {
3872 my $circ_lib = shift;
3873 my $setting = shift;
3874 my $date = $self->circ->due_date;
3876 return $date unless $setting and
3877 $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3879 my $xact = $self->editor->retrieve_money_billable_transaction([
3881 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3884 if ($xact->summary) {
3885 $date = $xact->summary->last_payment_ts ||
3886 $xact->summary->last_billing_ts ||
3887 $self->circ->due_date;
3894 sub checkin_handle_lost_or_longoverdue {
3895 my ($self, %args) = @_;
3897 my $circ = $self->circ;
3898 my $max_return = $args{max_return};
3899 my $circ_lib = $args{circ_lib};
3904 $self->get_circ_last_billing_activity(
3905 $circ_lib, $args{ous_use_last_activity});
3908 my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3909 $tm[5] -= 1 if $tm[5] > 0;
3910 my $due = timelocal(int($tm[1]), int($tm[2]),
3911 int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3914 OpenILS::Utils::DateTime->interval_to_seconds($max_return) + int($due);
3916 $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3917 "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3918 "DUE: $due LAST: $last_chance");
3920 $max_return = 0 if $today < $last_chance;
3926 $logger->info("circulator: check-in of lost/lo item exceeds max ".
3927 "return interval. skipping fine/fee voiding, etc.");
3929 } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3931 $logger->info("circulator: check-in of lost/lo item having a balance ".
3932 "of zero, skipping fine/fee voiding and reinstatement.");
3934 } else { # within max-return interval or no interval defined
3936 $logger->info("circulator: check-in of lost/lo item is within the ".
3937 "max return interval (or no interval is defined). Proceeding ".
3938 "with fine/fee voiding, etc.");
3940 $self->needs_lost_bill_handling(1);
3943 if ($circ_lib != $self->circ_lib) {
3944 # if the item is not home, check to see if we want to retain the
3945 # lost/longoverdue status at this point in the process
3947 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib,
3948 $args{ous_immediately_available}, $self->editor) || 0;
3950 if ($immediately_available) {
3951 # item status does not need to be retained, so give it a
3952 # reshelving status as if it were a normal checkin
3953 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3954 $self->copy->status($U->copy_status($next_status));
3957 $logger->info("circulator: leaving lost/longoverdue copy".
3958 " status in place on checkin");
3961 # lost/longoverdue item is home and processed, treat like a normal
3962 # checkin from this point on
3963 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3964 $self->copy->status($U->copy_status($next_status));
3970 sub checkin_handle_backdate {
3973 # ------------------------------------------------------------------
3974 # clean up the backdate for date comparison
3975 # XXX We are currently taking the due-time from the original due-date,
3976 # not the input. Do we need to do this? This certainly interferes with
3977 # backdating of hourly checkouts, but that is likely a very rare case.
3978 # ------------------------------------------------------------------
3979 my $bd = clean_ISO8601($self->backdate);
3980 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->circ->due_date));
3981 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3982 $new_date->set_hour($original_date->hour());
3983 $new_date->set_minute($original_date->minute());
3984 if ($new_date >= DateTime->now) {
3985 # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3986 # $self->backdate() autoload handler ignores undef values.
3987 # Clear the backdate manually.
3988 $logger->info("circulator: ignoring future backdate: $new_date");
3989 delete $self->{backdate};
3991 $self->backdate(clean_ISO8601($new_date->datetime()));
3998 sub check_checkin_copy_status {
4000 my $copy = $self->copy;
4002 my $status = $U->copy_status($copy->status)->id;
4005 if( $self->new_copy_alerts ||
4006 $status == OILS_COPY_STATUS_AVAILABLE ||
4007 $status == OILS_COPY_STATUS_CHECKED_OUT ||
4008 $status == OILS_COPY_STATUS_IN_PROCESS ||
4009 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
4010 $status == OILS_COPY_STATUS_IN_TRANSIT ||
4011 $status == OILS_COPY_STATUS_CATALOGING ||
4012 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
4013 $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
4014 $status == OILS_COPY_STATUS_RESHELVING );
4016 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
4017 if( $status == OILS_COPY_STATUS_LOST );
4019 return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
4020 if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
4022 return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
4023 if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
4025 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
4026 if( $status == OILS_COPY_STATUS_MISSING );
4028 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
4033 # --------------------------------------------------------------------------
4034 # On checkin, we need to return as many relevant objects as we can
4035 # --------------------------------------------------------------------------
4036 sub checkin_flesh_events {
4039 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
4040 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
4041 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
4044 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
4047 if($self->hold and !$self->hold->cancel_time) {
4048 $hold = $self->hold;
4049 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
4053 # update our copy of the circ object and
4054 # flesh the billing summary data
4056 $self->editor->retrieve_action_circulation([
4060 circ => ['billable_transaction'],
4069 # flesh some patron fields before returning
4071 $self->editor->retrieve_actor_user([
4076 au => ['card', 'billing_address', 'mailing_address']
4083 # Flesh the latest inventory.
4084 # NB: This survives the unflesh_copy below. Let's keep it that way.
4085 my $alci = $self->editor->search_asset_latest_inventory([
4086 {copy=>$self->copy->id},
4089 alci => ['inventory_workstation']
4091 if ($alci && $alci->[0]) {
4092 $self->copy->latest_inventory($alci->[0]);
4095 for my $evt (@{$self->events}) {
4098 $payload->{copy} = $U->unflesh_copy($self->copy);
4099 $payload->{volume} = $self->volume;
4100 $payload->{record} = $record,
4101 $payload->{circ} = $self->circ;
4102 $payload->{transit} = $self->transit;
4103 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
4104 $payload->{hold} = $hold;
4105 $payload->{patron} = $self->patron;
4106 $payload->{reservation} = $self->reservation
4107 unless (not $self->reservation or $self->reservation->cancel_time);
4109 $evt->{payload} = $payload;
4114 my( $self, $msg ) = @_;
4115 my $bc = ($self->copy) ? $self->copy->barcode :
4116 $self->copy_barcode;
4118 my $usr = ($self->patron) ? $self->patron->id : "";
4119 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
4120 ", recipient=$usr, copy=$bc");
4127 $self->log_me("do_renew()");
4129 # Make sure there is an open circ to renew
4130 my $usrid = $self->patron->id if $self->patron;
4131 my $circ = $self->editor->search_action_circulation({
4132 target_copy => $self->copy->id,
4133 xact_finish => undef,
4134 checkin_time => undef,
4135 ($usrid ? (usr => $usrid) : ())
4138 return $self->bail_on_events($self->editor->event) unless $circ;
4140 # A user is not allowed to renew another user's items without permission
4141 unless( $circ->usr eq $self->editor->requestor->id ) {
4142 return $self->bail_on_events($self->editor->events)
4143 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
4146 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
4147 if $circ->renewal_remaining < 1;
4149 $self->push_events(OpenILS::Event->new('MAX_AUTO_RENEWALS_REACHED'))
4150 if $self->auto_renewal and $circ->auto_renewal_remaining < 1;
4151 # -----------------------------------------------------------------
4153 $self->parent_circ($circ->id);
4154 $self->renewal_remaining( $circ->renewal_remaining - 1 );
4155 $self->auto_renewal_remaining( $circ->auto_renewal_remaining - 1 ) if (defined($circ->auto_renewal_remaining));
4158 # Opac renewal - re-use circ library from original circ (unless told not to)
4159 if($self->opac_renewal or $self->auto_renewal) {
4160 unless(defined($opac_renewal_use_circ_lib)) {
4161 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
4162 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4163 $opac_renewal_use_circ_lib = 1;
4166 $opac_renewal_use_circ_lib = 0;
4169 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4172 # Desk renewal - re-use circ library from original circ (unless told not to)
4173 if($self->desk_renewal) {
4174 unless(defined($desk_renewal_use_circ_lib)) {
4175 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4176 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4177 $desk_renewal_use_circ_lib = 1;
4180 $desk_renewal_use_circ_lib = 0;
4183 $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
4186 # Check if expired patron is allowed to renew, and bail if not.
4187 my $expire = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->patron->expire_date));
4188 if (CORE::time > $expire->epoch) {
4189 my $allow_renewal = $U->ou_ancestor_setting_value($self->circ_lib, OILS_SETTING_ALLOW_RENEW_FOR_EXPIRED_PATRON);
4190 unless ($U->is_true($allow_renewal)) {
4191 return $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'));
4195 # Run the fine generator against the old circ
4196 # XXX This seems unnecessary, given that handle_fines runs in do_checkin
4197 # a few lines down. Commenting out, for now.
4198 #$self->handle_fines;
4200 $self->run_renew_permit;
4203 $self->do_checkin();
4204 return if $self->bail_out;
4206 unless( $self->permit_override ) {
4208 return if $self->bail_out;
4209 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
4210 $self->remove_event('ITEM_NOT_CATALOGED');
4213 $self->override_events;
4214 return if $self->bail_out;
4217 $self->do_checkout();
4222 my( $self, $evt ) = @_;
4223 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4224 $logger->debug("circulator: removing event from list: $evt");
4225 my @events = @{$self->events};
4226 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
4231 my( $self, $evt ) = @_;
4232 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4233 return grep { $_->{textcode} eq $evt } @{$self->events};
4237 sub run_renew_permit {
4240 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
4241 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
4242 $self->editor, $self->copy, $self->editor->requestor, 1
4244 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
4247 my $results = $self->run_indb_circ_test;
4248 $self->push_events($self->matrix_test_result_events)
4249 unless $self->circ_test_success;
4253 # XXX: The primary mechanism for storing circ history is now handled
4254 # by tracking real circulation objects instead of bibs in a bucket.
4255 # However, this code is disabled by default and could be useful
4256 # some day, so may as well leave it for now.
4257 sub append_reading_list {
4261 $self->is_checkout and
4267 # verify history is globally enabled and uses the bucket mechanism
4268 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
4269 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
4271 return undef unless $htype and $htype eq 'bucket';
4273 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
4275 # verify the patron wants to retain the hisory
4276 my $setting = $e->search_actor_user_setting(
4277 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
4279 unless($setting and $setting->value) {
4284 my $bkt = $e->search_container_copy_bucket(
4285 {owner => $self->patron->id, btype => 'circ_history'})->[0];
4290 # find the next item position
4291 my $last_item = $e->search_container_copy_bucket_item(
4292 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
4293 $pos = $last_item->pos + 1 if $last_item;
4296 # create the history bucket if necessary
4297 $bkt = Fieldmapper::container::copy_bucket->new;
4298 $bkt->owner($self->patron->id);
4300 $bkt->btype('circ_history');
4302 $e->create_container_copy_bucket($bkt) or return $e->die_event;
4305 my $item = Fieldmapper::container::copy_bucket_item->new;
4307 $item->bucket($bkt->id);
4308 $item->target_copy($self->copy->id);
4311 $e->create_container_copy_bucket_item($item) or return $e->die_event;
4318 sub make_trigger_events {
4320 return unless $self->circ;
4321 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
4322 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
4323 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
4328 sub checkin_handle_lost_or_lo_now_found {
4329 my ($self, $bill_type, $is_longoverdue) = @_;
4331 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4333 $logger->debug("voiding $tag item billings");
4334 my $result = $CC->void_or_zero_bills_of_type($self->editor, $self->circ, $self->copy, $bill_type, "$tag ITEM RETURNED");
4335 $self->bail_on_events($self->editor->event) if ($result);
4338 sub checkin_handle_lost_or_lo_now_found_restore_od {
4340 my $circ_lib = shift;
4341 my $is_longoverdue = shift;
4342 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4344 # ------------------------------------------------------------------
4345 # restore those overdue charges voided when item was set to lost
4346 # ------------------------------------------------------------------
4348 my $ods = $self->editor->search_money_billing([
4350 xact => $self->circ->id,
4354 order_by => {mb => 'billing_ts desc'}
4358 $logger->debug("returning ".scalar(@$ods)." overdue charges pre-$tag");
4359 # Because actual users get up to all kinds of unexpectedness, we
4360 # only recreate up to $circ->max_fine in bills. I know you think
4361 # it wouldn't happen that bills could get created, voided, and
4362 # recreated more than once, but I guaran-damn-tee you that it will
4364 if ($ods && @$ods) {
4365 my $void_amount = 0;
4366 my $void_max = $self->circ->max_fine();
4367 # search for overdues voided the new way (aka "adjusted")
4368 my @billings = map {$_->id()} @$ods;
4369 my $voids = $self->editor->search_money_account_adjustment(
4371 billing => \@billings
4375 map {$void_amount += $_->amount()} @$voids;
4377 # if no adjustments found, assume they were voided the old way (aka "voided")
4378 for my $bill (@$ods) {
4379 if( $U->is_true($bill->voided) ) {
4380 $void_amount += $bill->amount();
4386 ($void_amount < $void_max ? $void_amount : $void_max),
4388 $ods->[0]->billing_type(),
4390 "System: $tag RETURNED - OVERDUES REINSTATED",
4391 $ods->[-1]->period_start(),
4392 $ods->[0]->period_end() # date this restoration the same as the last overdue (for possible subsequent fine generation)