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 $router_name = OpenSRF::Utils::Config
24 ->router_name || 'router';
26 my $ses = create OpenSRF::AppSession($router_name);
27 $booking_status = grep {$_ eq "open-ils.booking"} @{
28 $ses->request("opensrf.router.info.class.list")->gather(1)
31 $logger->info("booking status: " . ($booking_status ? "on" : "off"));
34 return $booking_status;
40 flesh_fields => {acp => ['call_number','parts','floating'], acn => ['record']}
43 # table of cases where suppressing a system-generated copy alerts
44 # should generate an override of an old-style event
45 my %COPY_ALERT_OVERRIDES = (
46 "CLAIMSRETURNED\tCHECKOUT" => ['CIRC_CLAIMS_RETURNED'],
47 "CLAIMSRETURNED\tCHECKIN" => ['CIRC_CLAIMS_RETURNED'],
48 "LOST\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
49 "LONGOVERDUE\tCHECKOUT" => ['OPEN_CIRCULATION_EXISTS'],
50 "MISSING\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
51 "DAMAGED\tCHECKOUT" => ['COPY_NOT_AVAILABLE'],
52 "LOST_AND_PAID\tCHECKOUT" => ['COPY_NOT_AVAILABLE', 'OPEN_CIRCULATION_EXISTS']
57 __PACKAGE__->register_method(
58 method => "run_method",
59 api_name => "open-ils.circ.checkout.permit",
61 Determines if the given checkout can occur
62 @param authtoken The login session key
63 @param params A trailing hash of named params including
64 barcode : The copy barcode,
65 patron : The patron the checkout is occurring for,
66 renew : true or false - whether or not this is a renewal
67 @return The event that occurred during the permit check.
71 __PACKAGE__->register_method (
72 method => 'run_method',
73 api_name => 'open-ils.circ.checkout.permit.override',
74 signature => q/@see open-ils.circ.checkout.permit/,
78 __PACKAGE__->register_method(
79 method => "run_method",
80 api_name => "open-ils.circ.checkout",
83 @param authtoken The login session key
84 @param params A named hash of params including:
86 barcode If no copy is provided, the copy is retrieved via barcode
87 copyid If no copy or barcode is provide, the copy id will be use
88 patron The patron's id
89 noncat True if this is a circulation for a non-cataloted item
90 noncat_type The non-cataloged type id
91 noncat_circ_lib The location for the noncat circ.
92 precat The item has yet to be cataloged
93 dummy_title The temporary title of the pre-cataloded item
94 dummy_author The temporary authr of the pre-cataloded item
95 Default is the home org of the staff member
96 @return The SUCCESS event on success, any other event depending on the error
99 __PACKAGE__->register_method(
100 method => "run_method",
101 api_name => "open-ils.circ.checkin",
104 Generic super-method for handling all copies
105 @param authtoken The login session key
106 @param params Hash of named parameters including:
107 barcode - The copy barcode
108 force - If true, copies in bad statuses will be checked in and give good statuses
109 noop - don't capture holds or put items into transit
110 void_overdues - void all overdues for the circulation (aka amnesty)
115 __PACKAGE__->register_method(
116 method => "run_method",
117 api_name => "open-ils.circ.checkin.override",
118 signature => q/@see open-ils.circ.checkin/
121 __PACKAGE__->register_method(
122 method => "run_method",
123 api_name => "open-ils.circ.renew.override",
124 signature => q/@see open-ils.circ.renew/,
128 __PACKAGE__->register_method(
129 method => "run_method",
130 api_name => "open-ils.circ.renew",
131 notes => <<" NOTES");
132 PARAMS( authtoken, circ => circ_id );
133 open-ils.circ.renew(login_session, circ_object);
134 Renews the provided circulation. login_session is the requestor of the
135 renewal and if the logged in user is not the same as circ->usr, then
136 the logged in user must have RENEW_CIRC permissions.
139 __PACKAGE__->register_method(
140 method => "run_method",
141 api_name => "open-ils.circ.checkout.full"
143 __PACKAGE__->register_method(
144 method => "run_method",
145 api_name => "open-ils.circ.checkout.full.override"
147 __PACKAGE__->register_method(
148 method => "run_method",
149 api_name => "open-ils.circ.reservation.pickup"
151 __PACKAGE__->register_method(
152 method => "run_method",
153 api_name => "open-ils.circ.reservation.return"
155 __PACKAGE__->register_method(
156 method => "run_method",
157 api_name => "open-ils.circ.reservation.return.override"
159 __PACKAGE__->register_method(
160 method => "run_method",
161 api_name => "open-ils.circ.checkout.inspect",
162 desc => q/Returns the circ matrix test result and, on success, the rule set and matrix test object/
167 my( $self, $conn, $auth, $args ) = @_;
168 translate_legacy_args($args);
169 $args->{override_args} = { all => 1 } unless defined $args->{override_args};
170 $args->{new_copy_alerts} ||= $self->api_level > 1 ? 1 : 0;
171 my $api = $self->api_name;
174 OpenILS::Application::Circ::Circulator->new($auth, %$args);
176 return circ_events($circulator) if $circulator->bail_out;
178 $circulator->use_booking(determine_booking_status());
180 # --------------------------------------------------------------------------
181 # First, check for a booking transit, as the barcode may not be a copy
182 # barcode, but a resource barcode, and nothing else in here will work
183 # --------------------------------------------------------------------------
185 if ($circulator->use_booking && (my $bc = $circulator->copy_barcode) && $api !~ /checkout|inspect/) { # do we have a barcode?
186 my $resources = $circulator->editor->search_booking_resource( { barcode => $bc } ); # any resources by this barcode?
187 if (@$resources) { # yes!
189 my $res_id_list = [ map { $_->id } @$resources ];
190 my $transit = $circulator->editor->search_action_reservation_transit_copy(
192 { target_copy => $res_id_list, dest => $circulator->circ_lib, dest_recv_time => undef, cancel_time => undef },
193 { order_by => { artc => 'source_send_time' }, limit => 1 }
195 )->[0]; # Any transit for this barcode?
197 if ($transit) { # yes! unwrap it.
199 my $reservation = $circulator->editor->retrieve_booking_reservation( $transit->reservation );
200 my $res_type = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
202 my $success_event = new OpenILS::Event(
203 "SUCCESS", "payload" => {"reservation" => $reservation}
205 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
206 if (my $copy = $circulator->editor->search_asset_copy([
207 { barcode => $bc, deleted => 'f' }, $MK_ENV_FLESH
208 ])->[0]) { # got a copy
209 $copy->status( $transit->copy_status );
210 $copy->editor($circulator->editor->requestor->id);
211 $copy->edit_date('now');
212 $circulator->editor->update_asset_copy($copy);
213 $success_event->{"payload"}->{"record"} =
214 $U->record_to_mvr($copy->call_number->record);
215 $success_event->{"payload"}->{"volume"} = $copy->call_number;
216 $copy->call_number($copy->call_number->id);
217 $success_event->{"payload"}->{"copy"} = $copy;
221 $transit->dest_recv_time('now');
222 $circulator->editor->update_action_reservation_transit_copy( $transit );
224 $circulator->editor->commit;
225 # Formerly this branch just stopped here. Argh!
226 $conn->respond_complete($success_event);
232 if ($circulator->use_booking) {
233 $circulator->is_res_checkin($circulator->is_checkin(1))
234 if $api =~ /reservation.return/ or (
235 $api =~ /checkin/ and $circulator->seems_like_reservation()
238 $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
241 $circulator->is_renewal(1) if $api =~ /renew/;
242 $circulator->is_checkin(1) if $api =~ /checkin/;
243 $circulator->is_checkout(1) if $api =~ /checkout/;
244 $circulator->override(1) if $api =~ /override/o;
246 $circulator->mk_env();
247 $circulator->noop(1) if $circulator->claims_never_checked_out;
249 return circ_events($circulator) if $circulator->bail_out;
251 if( $api =~ /checkout\.permit/ ) {
252 $circulator->do_permit();
254 } elsif( $api =~ /checkout.full/ ) {
256 # requesting a precat checkout implies that any required
257 # overrides have been performed. Go ahead and re-override.
258 $circulator->skip_permit_key(1);
259 $circulator->override(1) if $circulator->request_precat;
260 $circulator->do_permit();
261 $circulator->is_checkout(1);
262 unless( $circulator->bail_out ) {
263 $circulator->events([]);
264 $circulator->do_checkout();
267 } elsif( $circulator->is_res_checkout ) {
268 $circulator->do_reservation_pickup();
270 } elsif( $api =~ /inspect/ ) {
271 my $data = $circulator->do_inspect();
272 $circulator->editor->rollback;
275 } elsif( $api =~ /checkout/ ) {
276 $circulator->do_checkout();
278 } elsif( $circulator->is_res_checkin ) {
279 $circulator->do_reservation_return();
280 $circulator->do_checkin() if ($circulator->copy());
281 } elsif( $api =~ /checkin/ ) {
282 $circulator->do_checkin();
284 } elsif( $api =~ /renew/ ) {
285 $circulator->do_renew();
288 if( $circulator->bail_out ) {
291 # make sure no success event accidentally slip in
293 [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
296 my @e = @{$circulator->events};
297 push( @ee, $_->{textcode} ) for @e;
298 $logger->info("circulator: bailing out with events: " . (join ", ", @ee));
300 $circulator->editor->rollback;
304 # checkin and reservation return can result in modifications to
305 # actor.usr.claims_never_checked_out_count without also modifying
306 # actor.last_xact_id. Perform a no-op update on the patron to
307 # force an update to last_xact_id.
308 if ($circulator->claims_never_checked_out && $circulator->patron) {
309 $circulator->editor->update_actor_user(
310 $circulator->editor->retrieve_actor_user($circulator->patron->id))
311 or return $circulator->editor->die_event;
314 $circulator->editor->commit;
317 $conn->respond_complete(circ_events($circulator));
319 return undef if $circulator->bail_out;
321 $circulator->do_hold_notify($circulator->notify_hold)
322 if $circulator->notify_hold;
323 $circulator->retarget_holds if $circulator->retarget;
324 $circulator->append_reading_list;
325 $circulator->make_trigger_events;
332 my @e = @{$circ->events};
333 # if we have multiple events, SUCCESS should not be one of them;
334 @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
335 return (@e == 1) ? $e[0] : \@e;
339 sub translate_legacy_args {
342 if( $$args{barcode} ) {
343 $$args{copy_barcode} = $$args{barcode};
344 delete $$args{barcode};
347 if( $$args{copyid} ) {
348 $$args{copy_id} = $$args{copyid};
349 delete $$args{copyid};
352 if( $$args{patronid} ) {
353 $$args{patron_id} = $$args{patronid};
354 delete $$args{patronid};
357 if( $$args{patron} and !ref($$args{patron}) ) {
358 $$args{patron_id} = $$args{patron};
359 delete $$args{patron};
363 if( $$args{noncat} ) {
364 $$args{is_noncat} = $$args{noncat};
365 delete $$args{noncat};
368 if( $$args{precat} ) {
369 $$args{is_precat} = $$args{request_precat} = $$args{precat};
370 delete $$args{precat};
376 # --------------------------------------------------------------------------
377 # This package actually manages all of the circulation logic
378 # --------------------------------------------------------------------------
379 package OpenILS::Application::Circ::Circulator;
380 use strict; use warnings;
381 use vars q/$AUTOLOAD/;
383 use OpenILS::Utils::Fieldmapper;
384 use OpenSRF::Utils::Cache;
385 use Digest::MD5 qw(md5_hex);
386 use DateTime::Format::ISO8601;
387 use OpenILS::Utils::PermitHold;
388 use OpenSRF::Utils qw/:datetime/;
389 use OpenSRF::Utils::SettingsClient;
390 use OpenILS::Application::Circ::Holds;
391 use OpenILS::Application::Circ::Transit;
392 use OpenSRF::Utils::Logger qw(:logger);
393 use OpenILS::Utils::CStoreEditor qw/:funcs/;
394 use OpenILS::Const qw/:const/;
395 use OpenILS::Utils::Penalty;
396 use OpenILS::Application::Circ::CircCommon;
399 my $CC = "OpenILS::Application::Circ::CircCommon";
400 my $holdcode = "OpenILS::Application::Circ::Holds";
401 my $transcode = "OpenILS::Application::Circ::Transit";
407 # --------------------------------------------------------------------------
408 # Add a pile of automagic getter/setter methods
409 # --------------------------------------------------------------------------
410 my @AUTOLOAD_FIELDS = qw/
421 overrides_per_copy_alerts
462 recurring_fines_level
475 cancelled_hold_transit
482 circ_matrix_matchpoint
493 claims_never_checked_out
506 dont_change_lost_zero
508 needs_lost_bill_handling
514 my $type = ref($self) or die "$self is not an object";
516 my $name = $AUTOLOAD;
519 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
520 $logger->error("circulator: $type: invalid autoload field: $name");
521 die "$type: invalid autoload field: $name\n"
526 *{"${type}::${name}"} = sub {
529 $s->{$name} = $v if defined $v;
533 return $self->$name($data);
538 my( $class, $auth, %args ) = @_;
539 $class = ref($class) || $class;
540 my $self = bless( {}, $class );
543 $self->editor(new_editor(xact => 1, authtoken => $auth));
545 unless( $self->editor->checkauth ) {
546 $self->bail_on_events($self->editor->event);
550 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
552 $self->$_($args{$_}) for keys %args;
555 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
557 # if this is a renewal, default to desk_renewal
558 $self->desk_renewal(1) unless
559 $self->opac_renewal or $self->phone_renewal or $self->sip_renewal;
561 $self->capture('') unless $self->capture;
563 unless(%user_groups) {
564 my $gps = $self->editor->retrieve_all_permission_grp_tree;
565 %user_groups = map { $_->id => $_ } @$gps;
572 # --------------------------------------------------------------------------
573 # True if we should discontinue processing
574 # --------------------------------------------------------------------------
576 my( $self, $bool ) = @_;
577 if( defined $bool ) {
578 $logger->info("circulator: BAILING OUT") if $bool;
579 $self->{bail_out} = $bool;
581 return $self->{bail_out};
586 my( $self, @evts ) = @_;
589 $e->{payload} = $self->copy if
590 ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
592 $logger->info("circulator: pushing event ".$e->{textcode});
593 push( @{$self->events}, $e ) unless
594 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
600 return '' if $self->skip_permit_key;
601 my $key = md5_hex( time() . rand() . "$$" );
602 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
603 return $self->permit_key($key);
606 sub check_permit_key {
608 return 1 if $self->skip_permit_key;
609 my $key = $self->permit_key;
610 return 0 unless $key;
611 my $k = "oils_permit_key_$key";
612 my $one = $self->cache_handle->get_cache($k);
613 $self->cache_handle->delete_cache($k);
614 return ($one) ? 1 : 0;
617 sub seems_like_reservation {
620 # Some words about the following method:
621 # 1) It requires the VIEW_USER permission, but that's not an
622 # issue, right, since all staff should have that?
623 # 2) It returns only one reservation at a time, even if an item can be
624 # and is currently overbooked. Hmmm....
625 my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
626 my $result = $booking_ses->request(
627 "open-ils.booking.reservations.by_returnable_resource_barcode",
628 $self->editor->authtoken,
631 $booking_ses->disconnect;
633 return $self->bail_on_events($result) if defined $U->event_code($result);
636 $self->reservation(shift @$result);
644 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
645 sub save_trimmed_copy {
646 my ($self, $copy) = @_;
649 $self->volume($copy->call_number);
650 $self->title($self->volume->record);
651 $self->copy->call_number($self->volume->id);
652 $self->volume->record($self->title->id);
653 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
654 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
655 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
656 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
660 sub collect_user_copy_alerts {
662 my $e = $self->editor;
665 my $alerts = $e->search_asset_copy_alert([
666 {copy => $self->copy->id, ack_time => undef},
667 {flesh => 1, flesh_fields => { aca => [ qw/ alert_type / ] }}
669 if (ref $alerts eq "ARRAY") {
670 $logger->info("circulator: found " . scalar(@$alerts) . " alerts for copy " .
672 $self->user_copy_alerts($alerts);
677 sub filter_user_copy_alerts {
680 my $e = $self->editor;
682 if(my $alerts = $self->user_copy_alerts) {
684 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
685 my $suppressions = $e->search_actor_copy_alert_suppress(
686 {org => $suppress_orgs}
690 foreach my $a (@$alerts) {
691 # filter on event type
692 if (defined $a->alert_type) {
693 next if ($a->alert_type->event eq 'CHECKIN' && !$self->is_checkin && !$self->is_renewal);
694 next if ($a->alert_type->event eq 'CHECKOUT' && !$self->is_checkout && !$self->is_renewal);
695 next if (defined $a->alert_type->in_renew && $U->is_true($a->alert_type->in_renew) && !$self->is_renewal);
696 next if (defined $a->alert_type->in_renew && !$U->is_true($a->alert_type->in_renew) && $self->is_renewal);
699 # filter on suppression
700 next if (grep { $a->alert_type->id == $_->alert_type} @$suppressions);
702 # filter on "only at circ lib"
703 if (defined $a->alert_type->at_circ) {
704 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
705 $self->copy->circ_lib->id : $self->copy->circ_lib;
706 my $orgs = $U->get_org_descendants($copy_circ_lib);
708 if ($U->is_true($a->alert_type->invert_location)) {
709 next if (grep {$_ == $self->circ_lib} @$orgs);
711 next unless (grep {$_ == $self->circ_lib} @$orgs);
715 # filter on "only at owning lib"
716 if (defined $a->alert_type->at_owning) {
717 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
718 $self->volume->owning_lib->id : $self->volume->owning_lib;
719 my $orgs = $U->get_org_descendants($copy_owning_lib);
721 if ($U->is_true($a->alert_type->invert_location)) {
722 next if (grep {$_ == $self->circ_lib} @$orgs);
724 next unless (grep {$_ == $self->circ_lib} @$orgs);
728 $a->alert_type->next_status([$U->unique_unnested_numbers($a->alert_type->next_status)]);
730 push @final_alerts, $a;
733 $self->user_copy_alerts(\@final_alerts);
737 sub generate_system_copy_alerts {
739 return unless($self->copy);
741 # don't create system copy alerts if the copy
742 # is in a normal state; we're assuming that there's
743 # never a need to generate a popup for each and every
744 # checkin or checkout of normal items. If this assumption
745 # proves false, then we'll need to add a way to explicitly specify
746 # that a copy alert type should never generate a system copy alert
747 return if $self->copy_state eq 'NORMAL';
749 my $e = $self->editor;
751 my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
752 my $suppressions = $e->search_actor_copy_alert_suppress(
753 {org => $suppress_orgs}
756 # events we care about ...
758 push(@$event, 'CHECKIN') if $self->is_checkin;
759 push(@$event, 'CHECKOUT') if $self->is_checkout;
760 return unless scalar(@$event);
762 my $alert_orgs = $U->get_org_ancestors($self->circ_lib);
763 my $alert_types = $e->search_config_copy_alert_type({
765 scope_org => $alert_orgs,
767 state => $self->copy_state,
768 '-or' => [ { in_renew => $self->is_renewal }, { in_renew => undef } ],
772 foreach my $a (@$alert_types) {
773 # filter on "only at circ lib"
774 if (defined $a->at_circ) {
775 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
776 $self->copy->circ_lib->id : $self->copy->circ_lib;
777 my $orgs = $U->get_org_descendants($copy_circ_lib);
779 if ($U->is_true($a->invert_location)) {
780 next if (grep {$_ == $self->circ_lib} @$orgs);
782 next unless (grep {$_ == $self->circ_lib} @$orgs);
786 # filter on "only at owning lib"
787 if (defined $a->at_owning) {
788 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
789 $self->volume->owning_lib->id : $self->volume->owning_lib;
790 my $orgs = $U->get_org_descendants($copy_owning_lib);
792 if ($U->is_true($a->invert_location)) {
793 next if (grep {$_ == $self->circ_lib} @$orgs);
795 next unless (grep {$_ == $self->circ_lib} @$orgs);
799 push @final_types, $a;
803 $logger->info("circulator: found " . scalar(@final_types) . " system alert types for copy" .
809 # keep track of conditions corresponding to suppressed
810 # system alerts, as these may be used to overridee
811 # certain old-style-events
812 my %auto_override_conditions = ();
813 foreach my $t (@final_types) {
814 if ($t->next_status) {
815 if (grep { $t->id == $_->alert_type } @$suppressions) {
818 $t->next_status([$U->unique_unnested_numbers($t->next_status)]);
822 my $alert = new Fieldmapper::asset::copy_alert ();
823 $alert->alert_type($t->id);
824 $alert->copy($self->copy->id);
826 $alert->create_staff($e->requestor->id);
827 $alert->create_time('now');
828 $alert->ack_staff($e->requestor->id);
829 $alert->ack_time('now');
831 $alert = $e->create_asset_copy_alert($alert);
835 $alert->alert_type($t->clone);
837 push(@{$self->next_copy_status}, @{$t->next_status}) if ($t->next_status);
838 if (grep {$_->alert_type == $t->id} @$suppressions) {
839 $auto_override_conditions{join("\t", $t->state, $t->event)} = 1;
841 push(@alerts, $alert) unless (grep {$_->alert_type == $t->id} @$suppressions);
844 $self->system_copy_alerts(\@alerts);
845 $self->overrides_per_copy_alerts(\%auto_override_conditions);
848 sub add_overrides_from_system_copy_alerts {
850 my $e = $self->editor;
852 foreach my $condition (keys %{$self->overrides_per_copy_alerts()}) {
853 if (exists $COPY_ALERT_OVERRIDES{$condition}) {
855 push @{$self->override_args->{events}}, @{ $COPY_ALERT_OVERRIDES{$condition} };
856 # special handling for long-overdue and lost checkouts
857 if (grep { $_ eq 'OPEN_CIRCULATION_EXISTS' } @{ $COPY_ALERT_OVERRIDES{$condition} }) {
858 my $state = (split /\t/, $condition, -1)[0];
860 if ($state eq 'LOST' or $state eq 'LOST_AND_PAID') {
861 $setting = 'circ.copy_alerts.forgive_fines_on_lost_checkin';
862 } elsif ($state eq 'LONGOVERDUE') {
863 $setting = 'circ.copy_alerts.forgive_fines_on_long_overdue_checkin';
867 my $forgive = $U->ou_ancestor_setting_value(
868 $self->circ_lib, $setting, $e
870 if ($U->is_true($forgive)) {
871 $self->void_overdues(1);
873 $self->noop(1); # do not attempt transits, just check it in
882 my $e = $self->editor;
884 $self->next_copy_status([]) unless (defined $self->next_copy_status);
885 $self->overrides_per_copy_alerts({}) unless (defined $self->overrides_per_copy_alerts);
887 # --------------------------------------------------------------------------
888 # Grab the fleshed copy
889 # --------------------------------------------------------------------------
890 unless($self->is_noncat) {
893 $copy = $e->retrieve_asset_copy(
894 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
896 } elsif( $self->copy_barcode ) {
898 $copy = $e->search_asset_copy(
899 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
900 } elsif( $self->reservation ) {
901 my $res = $e->json_query(
903 "select" => {"acp" => ["id"]},
908 "field" => "barcode",
912 "field" => "current_resource"
921 "id" => (ref $self->reservation) ?
922 $self->reservation->id : $self->reservation
927 if (ref $res eq "ARRAY" and scalar @$res) {
928 $logger->info("circulator: mapped reservation " .
929 $self->reservation . " to copy " . $res->[0]->{"id"});
930 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
935 $self->save_trimmed_copy($copy);
940 {from => ['asset.copy_state', $copy->id]}
941 )->[0]{'asset.copy_state'}
944 $self->generate_system_copy_alerts;
945 $self->add_overrides_from_system_copy_alerts;
946 $self->collect_user_copy_alerts;
947 $self->filter_user_copy_alerts;
950 # We can't renew if there is no copy
951 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
952 if $self->is_renewal;
957 # --------------------------------------------------------------------------
959 # --------------------------------------------------------------------------
963 flesh_fields => {au => [ qw/ card / ]}
966 if( $self->patron_id ) {
967 $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
968 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
970 } elsif( $self->patron_barcode ) {
972 # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
973 my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0]
974 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
976 $patron = $e->retrieve_actor_user($card->usr)
977 or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
979 # Use the card we looked up, not the patron's primary, for card active checks
980 $patron->card($card);
983 if( my $copy = $self->copy ) {
986 $flesh->{flesh_fields}->{circ} = ['usr'];
988 my $circ = $e->search_action_circulation([
989 {target_copy => $copy->id, checkin_time => undef}, $flesh
993 $patron = $circ->usr;
994 $circ->usr($patron->id); # de-flesh for consistency
1000 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
1001 unless $self->patron($patron) or $self->is_checkin;
1003 unless($self->is_checkin) {
1005 # Check for inactivity and patron reg. expiration
1007 $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
1008 unless $U->is_true($patron->active);
1010 $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
1011 unless $U->is_true($patron->card->active);
1013 my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1014 cleanse_ISO8601($patron->expire_date));
1016 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1017 if( CORE::time > $expire->epoch ) ;
1022 # --------------------------------------------------------------------------
1023 # Does the circ permit work
1024 # --------------------------------------------------------------------------
1028 $self->log_me("do_permit()");
1030 unless( $self->editor->requestor->id == $self->patron->id ) {
1031 return $self->bail_on_events($self->editor->event)
1032 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1035 $self->check_captured_holds();
1036 $self->do_copy_checks();
1037 return if $self->bail_out;
1038 $self->run_patron_permit_scripts();
1039 $self->run_copy_permit_scripts()
1040 unless $self->is_precat or $self->is_noncat;
1041 $self->check_item_deposit_events();
1042 $self->override_events();
1043 return if $self->bail_out;
1045 if($self->is_precat and not $self->request_precat) {
1047 OpenILS::Event->new(
1048 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1049 return $self->bail_out(1) unless $self->is_renewal;
1053 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1056 sub check_item_deposit_events {
1058 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
1059 if $self->is_deposit and not $self->is_deposit_exempt;
1060 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
1061 if $self->is_rental and not $self->is_rental_exempt;
1064 # returns true if the user is not required to pay deposits
1065 sub is_deposit_exempt {
1067 my $pid = (ref $self->patron->profile) ?
1068 $self->patron->profile->id : $self->patron->profile;
1069 my $groups = $U->ou_ancestor_setting_value(
1070 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1071 for my $grp (@$groups) {
1072 return 1 if $self->is_group_descendant($grp, $pid);
1077 # returns true if the user is not required to pay rental fees
1078 sub is_rental_exempt {
1080 my $pid = (ref $self->patron->profile) ?
1081 $self->patron->profile->id : $self->patron->profile;
1082 my $groups = $U->ou_ancestor_setting_value(
1083 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1084 for my $grp (@$groups) {
1085 return 1 if $self->is_group_descendant($grp, $pid);
1090 sub is_group_descendant {
1091 my($self, $p_id, $c_id) = @_;
1092 return 0 unless defined $p_id and defined $c_id;
1093 return 1 if $c_id == $p_id;
1094 while(my $grp = $user_groups{$c_id}) {
1095 $c_id = $grp->parent;
1096 return 0 unless defined $c_id;
1097 return 1 if $c_id == $p_id;
1102 sub check_captured_holds {
1104 my $copy = $self->copy;
1105 my $patron = $self->patron;
1107 return undef unless $copy;
1109 my $s = $U->copy_status($copy->status)->id;
1110 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1111 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1113 # Item is on the holds shelf, make sure it's going to the right person
1114 my $hold = $self->editor->search_action_hold_request(
1117 current_copy => $copy->id ,
1118 capture_time => { '!=' => undef },
1119 cancel_time => undef,
1120 fulfillment_time => undef
1124 flesh_fields => { ahr => ['usr'],
1125 au => ['family_name', 'first_given_name']
1131 if ($hold and $hold->usr == $patron->id) {
1132 $self->checkout_is_for_hold(1);
1136 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1139 my $holdau = $hold->usr;
1142 $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1144 $payload->{patron_name} = "???";
1146 $payload->{hold_id} = $hold->id;
1147 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1148 payload => $payload));
1152 sub do_copy_checks {
1154 my $copy = $self->copy;
1155 return unless $copy;
1157 my $stat = $U->copy_status($copy->status)->id;
1159 # We cannot check out a copy if it is in-transit
1160 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1161 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1164 $self->handle_claims_returned();
1165 return if $self->bail_out;
1167 # no claims returned circ was found, check if there is any open circ
1168 unless( $self->is_renewal ) {
1170 my $circs = $self->editor->search_action_circulation(
1171 { target_copy => $copy->id, checkin_time => undef }
1174 if(my $old_circ = $circs->[0]) { # an open circ was found
1176 my $payload = {copy => $copy};
1178 if($old_circ->usr == $self->patron->id) {
1180 $payload->{old_circ} = $old_circ;
1182 # If there is an open circulation on the checkout item and an auto-renew
1183 # interval is defined, inform the caller that they should go
1184 # ahead and renew the item instead of warning about open circulations.
1186 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1188 'circ.checkout_auto_renew_age',
1192 if($auto_renew_intvl) {
1193 my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
1194 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
1196 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1197 $payload->{auto_renew} = 1;
1202 return $self->bail_on_events(
1203 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1209 my $LEGACY_CIRC_EVENT_MAP = {
1210 'no_item' => 'ITEM_NOT_CATALOGED',
1211 'actor.usr.barred' => 'PATRON_BARRED',
1212 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1213 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1214 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1215 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1216 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1217 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1218 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1219 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1220 'config.circ_matrix_test.total_copy_hold_ratio' =>
1221 'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1222 'config.circ_matrix_test.available_copy_hold_ratio' =>
1223 'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1227 # ---------------------------------------------------------------------
1228 # This pushes any patron-related events into the list but does not
1229 # set bail_out for any events
1230 # ---------------------------------------------------------------------
1231 sub run_patron_permit_scripts {
1233 my $patronid = $self->patron->id;
1238 my $results = $self->run_indb_circ_test;
1239 unless($self->circ_test_success) {
1240 my @trimmed_results;
1242 if ($self->is_noncat) {
1243 # no_item result is OK during noncat checkout
1244 @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1248 if ($self->checkout_is_for_hold) {
1249 # if this checkout will fulfill a hold, ignore CIRC blocks
1250 # and rely instead on the (later-checked) FULFILL block
1252 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1253 my $fblock_pens = $self->editor->search_config_standing_penalty(
1254 {name => [@pen_names], block_list => {like => '%CIRC%'}});
1256 for my $res (@$results) {
1257 my $name = $res->{fail_part} || '';
1258 next if grep {$_->name eq $name} @$fblock_pens;
1259 push(@trimmed_results, $res);
1263 # not for hold or noncat
1264 @trimmed_results = @$results;
1268 # update the final set of test results
1269 $self->matrix_test_result(\@trimmed_results);
1271 push @allevents, $self->matrix_test_result_events;
1275 $_->{payload} = $self->copy if
1276 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1279 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1281 $self->push_events(@allevents);
1284 sub matrix_test_result_codes {
1286 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1289 sub matrix_test_result_events {
1292 my $event = new OpenILS::Event(
1293 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1295 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1297 } (@{$self->matrix_test_result});
1300 sub run_indb_circ_test {
1302 return $self->matrix_test_result if $self->matrix_test_result;
1304 my $dbfunc = ($self->is_renewal) ?
1305 'action.item_user_renew_test' : 'action.item_user_circ_test';
1307 if( $self->is_precat && $self->request_precat) {
1308 $self->make_precat_copy;
1309 return if $self->bail_out;
1312 my $results = $self->editor->json_query(
1316 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1322 $self->circ_test_success($U->is_true($results->[0]->{success}));
1324 if(my $mp = $results->[0]->{matchpoint}) {
1325 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1326 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1327 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1328 if(defined($results->[0]->{renewals})) {
1329 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1331 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1332 if(defined($results->[0]->{grace_period})) {
1333 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1335 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1336 if(defined($results->[0]->{hard_due_date})) {
1337 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1339 # Grab the *last* response for limit_groups, where it is more likely to be filled
1340 $self->limit_groups($results->[-1]->{limit_groups});
1343 return $self->matrix_test_result($results);
1346 # ---------------------------------------------------------------------
1347 # given a use and copy, this will calculate the circulation policy
1348 # parameters. Only works with in-db circ.
1349 # ---------------------------------------------------------------------
1353 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1355 $self->run_indb_circ_test;
1358 circ_test_success => $self->circ_test_success,
1359 failure_events => [],
1360 failure_codes => [],
1361 matchpoint => $self->circ_matrix_matchpoint
1364 unless($self->circ_test_success) {
1365 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1366 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1369 if($self->circ_matrix_matchpoint) {
1370 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1371 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1372 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1373 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1375 my $policy = $self->get_circ_policy(
1376 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1378 $$results{$_} = $$policy{$_} for keys %$policy;
1384 # ---------------------------------------------------------------------
1385 # Loads the circ policy info for duration, recurring fine, and max
1386 # fine based on the current copy
1387 # ---------------------------------------------------------------------
1388 sub get_circ_policy {
1389 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1392 duration_rule => $duration_rule->name,
1393 recurring_fine_rule => $recurring_fine_rule->name,
1394 max_fine_rule => $max_fine_rule->name,
1395 max_fine => $self->get_max_fine_amount($max_fine_rule),
1396 fine_interval => $recurring_fine_rule->recurrence_interval,
1397 renewal_remaining => $duration_rule->max_renewals,
1398 grace_period => $recurring_fine_rule->grace_period
1401 if($hard_due_date) {
1402 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1403 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1406 $policy->{duration_date_ceiling} = undef;
1407 $policy->{duration_date_ceiling_force} = undef;
1410 $policy->{duration} = $duration_rule->shrt
1411 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1412 $policy->{duration} = $duration_rule->normal
1413 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1414 $policy->{duration} = $duration_rule->extended
1415 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1417 $policy->{recurring_fine} = $recurring_fine_rule->low
1418 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1419 $policy->{recurring_fine} = $recurring_fine_rule->normal
1420 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1421 $policy->{recurring_fine} = $recurring_fine_rule->high
1422 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1427 sub get_max_fine_amount {
1429 my $max_fine_rule = shift;
1430 my $max_amount = $max_fine_rule->amount;
1432 # if is_percent is true then the max->amount is
1433 # use as a percentage of the copy price
1434 if ($U->is_true($max_fine_rule->is_percent)) {
1435 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1436 $max_amount = $price * $max_fine_rule->amount / 100;
1438 $U->ou_ancestor_setting_value(
1440 'circ.max_fine.cap_at_price',
1444 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1445 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1453 sub run_copy_permit_scripts {
1455 my $copy = $self->copy || return;
1459 my $results = $self->run_indb_circ_test;
1460 push @allevents, $self->matrix_test_result_events
1461 unless $self->circ_test_success;
1463 # See if this copy has an alert message
1464 my $ae = $self->check_copy_alert();
1465 push( @allevents, $ae ) if $ae;
1467 # uniquify the events
1468 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1469 @allevents = values %hash;
1471 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1473 $self->push_events(@allevents);
1477 sub check_copy_alert {
1480 if ($self->new_copy_alerts) {
1482 push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts
1483 if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1485 push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts
1486 if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1489 $self->bail_out(1) if (!$self->override);
1490 return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1494 return undef if $self->is_renewal;
1495 return OpenILS::Event->new(
1496 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1497 if $self->copy and $self->copy->alert_message;
1503 # --------------------------------------------------------------------------
1504 # If the call is overriding and has permissions to override every collected
1505 # event, the are cleared. Any event that the caller does not have
1506 # permission to override, will be left in the event list and bail_out will
1508 # XXX We need code in here to cancel any holds/transits on copies
1509 # that are being force-checked out
1510 # --------------------------------------------------------------------------
1511 sub override_events {
1513 my @events = @{$self->events};
1514 return unless @events;
1515 my $oargs = $self->override_args;
1517 if(!$self->override) {
1518 return $self->bail_out(1)
1519 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1524 for my $e (@events) {
1525 my $tc = $e->{textcode};
1526 next if $tc eq 'SUCCESS';
1527 if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1528 my $ov = "$tc.override";
1529 $logger->info("circulator: attempting to override event: $ov");
1531 return $self->bail_on_events($self->editor->event)
1532 unless( $self->editor->allowed($ov) );
1534 return $self->bail_out(1);
1540 # --------------------------------------------------------------------------
1541 # If there is an open claimsreturn circ on the requested copy, close the
1542 # circ if overriding, otherwise bail out
1543 # --------------------------------------------------------------------------
1544 sub handle_claims_returned {
1546 my $copy = $self->copy;
1548 my $CR = $self->editor->search_action_circulation(
1550 target_copy => $copy->id,
1551 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1552 checkin_time => undef,
1556 return unless ($CR = $CR->[0]);
1560 # - If the caller has set the override flag, we will check the item in
1561 if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1563 $CR->checkin_time('now');
1564 $CR->checkin_scan_time('now');
1565 $CR->checkin_lib($self->circ_lib);
1566 $CR->checkin_workstation($self->editor->requestor->wsid);
1567 $CR->checkin_staff($self->editor->requestor->id);
1569 $evt = $self->editor->event
1570 unless $self->editor->update_action_circulation($CR);
1573 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1576 $self->bail_on_events($evt) if $evt;
1581 # --------------------------------------------------------------------------
1582 # This performs the checkout
1583 # --------------------------------------------------------------------------
1587 $self->log_me("do_checkout()");
1589 # make sure perms are good if this isn't a renewal
1590 unless( $self->is_renewal ) {
1591 return $self->bail_on_events($self->editor->event)
1592 unless( $self->editor->allowed('COPY_CHECKOUT') );
1595 # verify the permit key
1596 unless( $self->check_permit_key ) {
1597 if( $self->permit_override ) {
1598 return $self->bail_on_events($self->editor->event)
1599 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1601 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1605 # if this is a non-cataloged circ, build the circ and finish
1606 if( $self->is_noncat ) {
1607 $self->checkout_noncat;
1609 OpenILS::Event->new('SUCCESS',
1610 payload => { noncat_circ => $self->circ }));
1614 if( $self->is_precat ) {
1615 $self->make_precat_copy;
1616 return if $self->bail_out;
1618 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1619 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1622 $self->do_copy_checks;
1623 return if $self->bail_out;
1625 $self->run_checkout_scripts();
1626 return if $self->bail_out;
1628 $self->build_checkout_circ_object();
1629 return if $self->bail_out;
1631 my $modify_to_start = $self->booking_adjusted_due_date();
1632 return if $self->bail_out;
1634 $self->apply_modified_due_date($modify_to_start);
1635 return if $self->bail_out;
1637 return $self->bail_on_events($self->editor->event)
1638 unless $self->editor->create_action_circulation($self->circ);
1640 # refresh the circ to force local time zone for now
1641 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1643 if($self->limit_groups) {
1644 $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1647 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1649 return if $self->bail_out;
1651 $self->apply_deposit_fee();
1652 return if $self->bail_out;
1654 $self->handle_checkout_holds();
1655 return if $self->bail_out;
1657 # ------------------------------------------------------------------------------
1658 # Update the patron penalty info in the DB. Run it for permit-overrides
1659 # since the penalties are not updated during the permit phase
1660 # ------------------------------------------------------------------------------
1661 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1663 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1666 if($self->is_renewal) {
1667 # flesh the billing summary for the checked-in circ
1668 $pcirc = $self->editor->retrieve_action_circulation([
1670 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1675 OpenILS::Event->new('SUCCESS',
1677 copy => $U->unflesh_copy($self->copy),
1678 volume => $self->volume,
1679 circ => $self->circ,
1681 holds_fulfilled => $self->fulfilled_holds,
1682 deposit_billing => $self->deposit_billing,
1683 rental_billing => $self->rental_billing,
1684 parent_circ => $pcirc,
1685 patron => ($self->return_patron) ? $self->patron : undef,
1686 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1692 sub apply_deposit_fee {
1694 my $copy = $self->copy;
1696 ($self->is_deposit and not $self->is_deposit_exempt) or
1697 ($self->is_rental and not $self->is_rental_exempt);
1699 return if $self->is_deposit and $self->skip_deposit_fee;
1700 return if $self->is_rental and $self->skip_rental_fee;
1702 my $bill = Fieldmapper::money::billing->new;
1703 my $amount = $copy->deposit_amount;
1707 if($self->is_deposit) {
1708 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1710 $self->deposit_billing($bill);
1712 $billing_type = OILS_BILLING_TYPE_RENTAL;
1714 $self->rental_billing($bill);
1717 $bill->xact($self->circ->id);
1718 $bill->amount($amount);
1719 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1720 $bill->billing_type($billing_type);
1721 $bill->btype($btype);
1722 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1724 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1729 my $copy = $self->copy;
1731 my $stat = $copy->status if ref $copy->status;
1732 my $loc = $copy->location if ref $copy->location;
1733 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1735 $copy->status($stat->id) if $stat;
1736 $copy->location($loc->id) if $loc;
1737 $copy->circ_lib($circ_lib->id) if $circ_lib;
1738 $copy->editor($self->editor->requestor->id);
1739 $copy->edit_date('now');
1740 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1742 return $self->bail_on_events($self->editor->event)
1743 unless $self->editor->update_asset_copy($self->copy);
1745 $copy->status($U->copy_status($copy->status));
1746 $copy->location($loc) if $loc;
1747 $copy->circ_lib($circ_lib) if $circ_lib;
1750 sub update_reservation {
1752 my $reservation = $self->reservation;
1754 my $usr = $reservation->usr;
1755 my $target_rt = $reservation->target_resource_type;
1756 my $target_r = $reservation->target_resource;
1757 my $current_r = $reservation->current_resource;
1759 $reservation->usr($usr->id) if ref $usr;
1760 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1761 $reservation->target_resource($target_r->id) if ref $target_r;
1762 $reservation->current_resource($current_r->id) if ref $current_r;
1764 return $self->bail_on_events($self->editor->event)
1765 unless $self->editor->update_booking_reservation($self->reservation);
1768 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1769 $self->reservation($reservation);
1773 sub bail_on_events {
1774 my( $self, @evts ) = @_;
1775 $self->push_events(@evts);
1779 # ------------------------------------------------------------------------------
1780 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1781 # affects copies that will fulfill holds and CIRC affects all other copies.
1782 # If blocks exists, bail, push Events onto the event pile, and return true.
1783 # ------------------------------------------------------------------------------
1784 sub check_hold_fulfill_blocks {
1787 # With the addition of ignore_proximity in csp, we need to fetch
1788 # the proximity of both the circ_lib and the copy's circ_lib to
1789 # the patron's home_ou.
1790 my ($ou_prox, $copy_prox);
1791 my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1792 $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1793 $ou_prox = -1 unless (defined($ou_prox));
1794 my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1795 if ($copy_ou == $self->circ_lib) {
1796 # Save us the time of an extra query.
1797 $copy_prox = $ou_prox;
1799 $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1800 $copy_prox = -1 unless (defined($copy_prox));
1803 # See if the user has any penalties applied that prevent hold fulfillment
1804 my $pens = $self->editor->json_query({
1805 select => {csp => ['name', 'label']},
1806 from => {ausp => {csp => {}}},
1809 usr => $self->patron->id,
1810 org_unit => $U->get_org_full_path($self->circ_lib),
1812 {stop_date => undef},
1813 {stop_date => {'>' => 'now'}}
1817 block_list => {'like' => '%FULFILL%'},
1819 {ignore_proximity => undef},
1820 {ignore_proximity => {'<' => $ou_prox}},
1821 {ignore_proximity => {'<' => $copy_prox}}
1827 return 0 unless @$pens;
1829 for my $pen (@$pens) {
1830 $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1831 my $event = OpenILS::Event->new($pen->{name});
1832 $event->{desc} = $pen->{label};
1833 $self->push_events($event);
1836 $self->override_events;
1837 return $self->bail_out;
1841 # ------------------------------------------------------------------------------
1842 # When an item is checked out, see if we can fulfill a hold for this patron
1843 # ------------------------------------------------------------------------------
1844 sub handle_checkout_holds {
1846 my $copy = $self->copy;
1847 my $patron = $self->patron;
1849 my $e = $self->editor;
1850 $self->fulfilled_holds([]);
1852 # non-cats can't fulfill a hold
1853 return if $self->is_noncat;
1855 my $hold = $e->search_action_hold_request({
1856 current_copy => $copy->id ,
1857 cancel_time => undef,
1858 fulfillment_time => undef
1861 if($hold and $hold->usr != $patron->id) {
1862 # reset the hold since the copy is now checked out
1864 $logger->info("circulator: un-targeting hold ".$hold->id.
1865 " because copy ".$copy->id." is getting checked out");
1867 $hold->clear_prev_check_time;
1868 $hold->clear_current_copy;
1869 $hold->clear_capture_time;
1870 $hold->clear_shelf_time;
1871 $hold->clear_shelf_expire_time;
1872 $hold->clear_current_shelf_lib;
1874 return $self->bail_on_event($e->event)
1875 unless $e->update_action_hold_request($hold);
1881 $hold = $self->find_related_user_hold($copy, $patron) or return;
1882 $logger->info("circulator: found related hold to fulfill in checkout");
1885 return if $self->check_hold_fulfill_blocks;
1887 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1889 # if the hold was never officially captured, capture it.
1890 $hold->current_copy($copy->id);
1891 $hold->capture_time('now') unless $hold->capture_time;
1892 $hold->fulfillment_time('now');
1893 $hold->fulfillment_staff($e->requestor->id);
1894 $hold->fulfillment_lib($self->circ_lib);
1896 return $self->bail_on_events($e->event)
1897 unless $e->update_action_hold_request($hold);
1899 return $self->fulfilled_holds([$hold->id]);
1903 # ------------------------------------------------------------------------------
1904 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1905 # the patron directly targets the checked out item, see if there is another hold
1906 # for the patron that could be fulfilled by the checked out item. Fulfill the
1907 # oldest hold and only fulfill 1 of them.
1909 # For "another hold":
1911 # First, check for one that the copy matches via hold_copy_map, ensuring that
1912 # *any* hold type that this copy could fill may end up filled.
1914 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1915 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1916 # that are non-requestable to count as capturing those hold types.
1917 # ------------------------------------------------------------------------------
1918 sub find_related_user_hold {
1919 my($self, $copy, $patron) = @_;
1920 my $e = $self->editor;
1922 # holds on precat copies are always copy-level, so this call will
1923 # always return undef. Exit early.
1924 return undef if $self->is_precat;
1926 return undef unless $U->ou_ancestor_setting_value(
1927 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1929 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1931 select => {ahr => ['id']},
1940 fkey => 'current_copy',
1941 type => 'left' # there may be no current_copy
1948 fulfillment_time => undef,
1949 cancel_time => undef,
1951 {expire_time => undef},
1952 {expire_time => {'>' => 'now'}}
1956 target_copy => $self->copy->id
1960 {id => undef}, # left-join copy may be nonexistent
1961 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1965 order_by => {ahr => {request_time => {direction => 'asc'}}},
1969 my $hold_info = $e->json_query($args)->[0];
1970 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1971 return undef if $U->ou_ancestor_setting_value(
1972 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1974 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1976 select => {ahr => ['id']},
1981 fkey => 'current_copy',
1982 type => 'left' # there may be no current_copy
1989 fulfillment_time => undef,
1990 cancel_time => undef,
1992 {expire_time => undef},
1993 {expire_time => {'>' => 'now'}}
2000 target => $self->volume->id
2006 target => $self->title->id
2012 {id => undef}, # left-join copy may be nonexistent
2013 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2017 order_by => {ahr => {request_time => {direction => 'asc'}}},
2021 $hold_info = $e->json_query($args)->[0];
2022 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2027 sub run_checkout_scripts {
2040 my $hard_due_date_name;
2042 $self->run_indb_circ_test();
2043 $duration = $self->circ_matrix_matchpoint->duration_rule;
2044 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2045 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2046 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2048 $duration_name = $duration->name if $duration;
2049 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2052 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2053 return $self->bail_on_events($evt) if ($evt && !$nobail);
2055 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2056 return $self->bail_on_events($evt) if ($evt && !$nobail);
2058 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2059 return $self->bail_on_events($evt) if ($evt && !$nobail);
2061 if($hard_due_date_name) {
2062 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2063 return $self->bail_on_events($evt) if ($evt && !$nobail);
2069 # The item circulates with an unlimited duration
2073 $hard_due_date = undef;
2076 $self->duration_rule($duration);
2077 $self->recurring_fines_rule($recurring);
2078 $self->max_fine_rule($max_fine);
2079 $self->hard_due_date($hard_due_date);
2083 sub build_checkout_circ_object {
2086 my $circ = Fieldmapper::action::circulation->new;
2087 my $duration = $self->duration_rule;
2088 my $max = $self->max_fine_rule;
2089 my $recurring = $self->recurring_fines_rule;
2090 my $hard_due_date = $self->hard_due_date;
2091 my $copy = $self->copy;
2092 my $patron = $self->patron;
2093 my $duration_date_ceiling;
2094 my $duration_date_ceiling_force;
2098 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2099 $duration_date_ceiling = $policy->{duration_date_ceiling};
2100 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2102 my $dname = $duration->name;
2103 my $mname = $max->name;
2104 my $rname = $recurring->name;
2106 if($hard_due_date) {
2107 $hdname = $hard_due_date->name;
2110 $logger->debug("circulator: building circulation ".
2111 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2113 $circ->duration($policy->{duration});
2114 $circ->recurring_fine($policy->{recurring_fine});
2115 $circ->duration_rule($duration->name);
2116 $circ->recurring_fine_rule($recurring->name);
2117 $circ->max_fine_rule($max->name);
2118 $circ->max_fine($policy->{max_fine});
2119 $circ->fine_interval($recurring->recurrence_interval);
2120 $circ->renewal_remaining($duration->max_renewals);
2121 $circ->grace_period($policy->{grace_period});
2125 $logger->info("circulator: copy found with an unlimited circ duration");
2126 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2127 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2128 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2129 $circ->renewal_remaining(0);
2130 $circ->grace_period(0);
2133 $circ->target_copy( $copy->id );
2134 $circ->usr( $patron->id );
2135 $circ->circ_lib( $self->circ_lib );
2136 $circ->workstation($self->editor->requestor->wsid)
2137 if defined $self->editor->requestor->wsid;
2139 # renewals maintain a link to the parent circulation
2140 $circ->parent_circ($self->parent_circ);
2142 if( $self->is_renewal ) {
2143 $circ->opac_renewal('t') if $self->opac_renewal;
2144 $circ->phone_renewal('t') if $self->phone_renewal;
2145 $circ->desk_renewal('t') if $self->desk_renewal;
2146 $circ->renewal_remaining($self->renewal_remaining);
2147 $circ->circ_staff($self->editor->requestor->id);
2151 # if the user provided an overiding checkout time,
2152 # (e.g. the checkout really happened several hours ago), then
2153 # we apply that here. Does this need a perm??
2154 $circ->xact_start(cleanse_ISO8601($self->checkout_time))
2155 if $self->checkout_time;
2157 # if a patron is renewing, 'requestor' will be the patron
2158 $circ->circ_staff($self->editor->requestor->id);
2159 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2164 sub do_reservation_pickup {
2167 $self->log_me("do_reservation_pickup()");
2169 $self->reservation->pickup_time('now');
2172 $self->reservation->current_resource &&
2173 $U->is_true($self->reservation->target_resource_type->catalog_item)
2175 # We used to try to set $self->copy and $self->patron here,
2176 # but that should already be done.
2178 $self->run_checkout_scripts(1);
2180 my $duration = $self->duration_rule;
2181 my $max = $self->max_fine_rule;
2182 my $recurring = $self->recurring_fines_rule;
2184 if ($duration && $max && $recurring) {
2185 my $policy = $self->get_circ_policy($duration, $recurring, $max);
2187 my $dname = $duration->name;
2188 my $mname = $max->name;
2189 my $rname = $recurring->name;
2191 $logger->debug("circulator: updating reservation ".
2192 "with duration=$dname, maxfine=$mname, recurring=$rname");
2194 $self->reservation->fine_amount($policy->{recurring_fine});
2195 $self->reservation->max_fine($policy->{max_fine});
2196 $self->reservation->fine_interval($recurring->recurrence_interval);
2199 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2200 $self->update_copy();
2203 $self->reservation->fine_amount(
2204 $self->reservation->target_resource_type->fine_amount
2206 $self->reservation->max_fine(
2207 $self->reservation->target_resource_type->max_fine
2209 $self->reservation->fine_interval(
2210 $self->reservation->target_resource_type->fine_interval
2214 $self->update_reservation();
2217 sub do_reservation_return {
2219 my $request = shift;
2221 $self->log_me("do_reservation_return()");
2223 if (not ref $self->reservation) {
2224 my ($reservation, $evt) =
2225 $U->fetch_booking_reservation($self->reservation);
2226 return $self->bail_on_events($evt) if $evt;
2227 $self->reservation($reservation);
2230 $self->handle_fines(1);
2231 $self->reservation->return_time('now');
2232 $self->update_reservation();
2233 $self->reshelve_copy if $self->copy;
2235 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2236 $self->copy( $self->reservation->current_resource->catalog_item );
2240 sub booking_adjusted_due_date {
2242 my $circ = $self->circ;
2243 my $copy = $self->copy;
2245 return undef unless $self->use_booking;
2249 if( $self->due_date ) {
2251 return $self->bail_on_events($self->editor->event)
2252 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2254 $circ->due_date(cleanse_ISO8601($self->due_date));
2258 return unless $copy and $circ->due_date;
2261 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2262 if (@$booking_items) {
2263 my $booking_item = $booking_items->[0];
2264 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2266 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2267 my $shorten_circ_setting = $resource_type->elbow_room ||
2268 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2271 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2272 my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2273 resource => $booking_item->id
2274 , search_start => 'now'
2275 , search_end => $circ->due_date
2276 , fields => { cancel_time => undef, return_time => undef }
2278 $booking_ses->disconnect;
2280 throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2281 return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2283 my $dt_parser = DateTime::Format::ISO8601->new;
2284 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2286 for my $bid (@$bookings) {
2288 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2290 my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2291 my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2293 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2294 if ($booking_start < DateTime->now);
2297 if ($U->is_true($stop_circ_setting)) {
2298 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2300 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2301 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2304 # We set the circ duration here only to affect the logic that will
2305 # later (in a DB trigger) mangle the time part of the due date to
2306 # 11:59pm. Having any circ duration that is not a whole number of
2307 # days is enough to prevent the "correction."
2308 my $new_circ_duration = $due_date->epoch - time;
2309 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2310 $circ->duration("$new_circ_duration seconds");
2312 $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2316 return $self->bail_on_events($self->editor->event)
2317 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2323 sub apply_modified_due_date {
2325 my $shift_earlier = shift;
2326 my $circ = $self->circ;
2327 my $copy = $self->copy;
2329 if( $self->due_date ) {
2331 return $self->bail_on_events($self->editor->event)
2332 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2334 $circ->due_date(cleanse_ISO8601($self->due_date));
2338 # if the due_date lands on a day when the location is closed
2339 return unless $copy and $circ->due_date;
2341 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2343 # due-date overlap should be determined by the location the item
2344 # is checked out from, not the owning or circ lib of the item
2345 my $org = $self->circ_lib;
2347 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2348 " with an item due date of ".$circ->due_date );
2350 my $dateinfo = $U->storagereq(
2351 'open-ils.storage.actor.org_unit.closed_date.overlap',
2352 $org, $circ->due_date );
2355 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2356 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2358 # XXX make the behavior more dynamic
2359 # for now, we just push the due date to after the close date
2360 if ($shift_earlier) {
2361 $circ->due_date($dateinfo->{start});
2363 $circ->due_date($dateinfo->{end});
2371 sub create_due_date {
2372 my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2374 # if there is a raw time component (e.g. from postgres),
2375 # turn it into an interval that interval_to_seconds can parse
2376 $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2378 # for now, use the server timezone. TODO: use workstation org timezone
2379 my $due_date = DateTime->now(time_zone => 'local');
2380 $due_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)) if $start_time;
2382 # add the circ duration
2383 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2386 my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2387 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2388 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2393 # return ISO8601 time with timezone
2394 return $due_date->strftime('%FT%T%z');
2399 sub make_precat_copy {
2401 my $copy = $self->copy;
2404 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2406 $copy->editor($self->editor->requestor->id);
2407 $copy->edit_date('now');
2408 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2409 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2410 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2411 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2412 $self->update_copy();
2416 $logger->info("circulator: Creating a new precataloged ".
2417 "copy in checkout with barcode " . $self->copy_barcode);
2419 $copy = Fieldmapper::asset::copy->new;
2420 $copy->circ_lib($self->circ_lib);
2421 $copy->creator($self->editor->requestor->id);
2422 $copy->editor($self->editor->requestor->id);
2423 $copy->barcode($self->copy_barcode);
2424 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2425 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2426 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2428 $copy->dummy_title($self->dummy_title || "");
2429 $copy->dummy_author($self->dummy_author || "");
2430 $copy->dummy_isbn($self->dummy_isbn || "");
2431 $copy->circ_modifier($self->circ_modifier);
2434 # See if we need to override the circ_lib for the copy with a configured circ_lib
2435 # Setting is shortname of the org unit
2436 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2437 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2439 if($precat_circ_lib) {
2440 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2443 $self->bail_on_events($self->editor->event);
2447 $copy->circ_lib($org->id);
2451 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2453 $self->push_events($self->editor->event);
2459 sub checkout_noncat {
2465 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2466 my $count = $self->noncat_count || 1;
2467 my $cotime = cleanse_ISO8601($self->checkout_time) || "";
2469 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2473 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2474 $self->editor->requestor->id,
2482 $self->push_events($evt);
2490 # If a copy goes into transit and is then checked in before the transit checkin
2491 # interval has expired, push an event onto the overridable events list.
2492 sub check_transit_checkin_interval {
2495 # only concerned with in-transit items
2496 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2498 # no interval, no problem
2499 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2500 return unless $interval;
2502 # capture the transit so we don't have to fetch it again later during checkin
2504 $self->editor->search_action_transit_copy(
2505 {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2509 # transit from X to X for whatever reason has no min interval
2510 return if $self->transit->source == $self->transit->dest;
2512 my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2513 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2514 my $horizon = $t_start->add(seconds => $seconds);
2516 # See if we are still within the transit checkin forbidden range
2517 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2518 if $horizon > DateTime->now;
2521 # Retarget local holds at checkin
2522 sub checkin_retarget {
2524 return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2525 return unless $self->is_checkin; # Renewals need not be checked
2526 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2527 return if $self->is_precat; # No holds for precats
2528 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2529 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2530 my $status = $U->copy_status($self->copy->status);
2531 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2532 # Specifically target items that are likely new (by status ID)
2533 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2534 my $location = $self->copy->location;
2535 if(!ref($location)) {
2536 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2537 $self->copy->location($location);
2539 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2541 # Fetch holds for the bib
2542 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2543 $self->editor->authtoken,
2546 capture_time => undef, # No touching captured holds
2547 frozen => 'f', # Don't bother with frozen holds
2548 pickup_lib => $self->circ_lib # Only holds actually here
2551 # Error? Skip the step.
2552 return if exists $result->{"ilsevent"};
2556 foreach my $holdlist (keys %{$result}) {
2557 push @$holds, @{$result->{$holdlist}};
2560 return if scalar(@$holds) == 0; # No holds, no retargeting
2562 # Check for parts on this copy
2563 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2564 my %parts_hash = ();
2565 %parts_hash = map {$_->part, 1} @$parts if @$parts;
2567 # Loop over holds in request-ish order
2568 # Stage 1: Get them into request-ish order
2569 # Also grab type and target for skipping low hanging ones
2570 $result = $self->editor->json_query({
2571 "select" => { "ahr" => ["id", "hold_type", "target"] },
2572 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2573 "where" => { "id" => $holds },
2575 { "class" => "pgt", "field" => "hold_priority"},
2576 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2577 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2578 { "class" => "ahr", "field" => "request_time"}
2583 if (ref $result eq "ARRAY" and scalar @$result) {
2584 foreach (@{$result}) {
2585 # Copy level, but not this copy?
2586 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2587 and $_->{target} != $self->copy->id);
2588 # Volume level, but not this volume?
2589 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2590 if(@$parts) { # We have parts?
2592 next if ($_->{hold_type} eq 'T');
2593 # Skip part holds for parts not on this copy
2594 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2596 # No parts, no part holds
2597 next if ($_->{hold_type} eq 'P');
2599 # So much for easy stuff, attempt a retarget!
2600 my $tresult = $U->simplereq(
2601 'open-ils.hold-targeter',
2602 'open-ils.hold-targeter.target',
2603 {hold => $_->{id}, find_copy => $self->copy->id}
2605 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2606 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2614 $self->log_me("do_checkin()");
2616 return $self->bail_on_events(
2617 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2620 $self->check_transit_checkin_interval;
2621 $self->checkin_retarget;
2623 # the renew code and mk_env should have already found our circulation object
2624 unless( $self->circ ) {
2626 my $circs = $self->editor->search_action_circulation(
2627 { target_copy => $self->copy->id, checkin_time => undef });
2629 $self->circ($$circs[0]);
2631 # for now, just warn if there are multiple open circs on a copy
2632 $logger->warn("circulator: we have ".scalar(@$circs).
2633 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2636 my $stat = $U->copy_status($self->copy->status)->id;
2638 # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2639 # differently if they are already paid for. We need to check for this
2640 # early since overdue generation is potentially affected.
2641 my $dont_change_lost_zero = 0;
2642 if ($stat == OILS_COPY_STATUS_LOST
2643 || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2644 || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2646 # LOST fine settings are controlled by the copy's circ lib, not the the
2648 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2649 $self->copy->circ_lib->id : $self->copy->circ_lib;
2650 $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2651 $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2652 $self->editor) || 0;
2654 if ($dont_change_lost_zero) {
2655 my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2656 $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2659 $self->dont_change_lost_zero($dont_change_lost_zero);
2662 if( $self->checkin_check_holds_shelf() ) {
2663 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2664 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2665 if($self->fake_hold_dest) {
2666 $self->hold->pickup_lib($self->circ_lib);
2668 $self->checkin_flesh_events;
2672 unless( $self->is_renewal ) {
2673 return $self->bail_on_events($self->editor->event)
2674 unless $self->editor->allowed('COPY_CHECKIN');
2677 $self->push_events($self->check_copy_alert());
2678 $self->push_events($self->check_checkin_copy_status());
2680 # if the circ is marked as 'claims returned', add the event to the list
2681 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2682 if ($self->circ and $self->circ->stop_fines
2683 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2685 $self->check_circ_deposit();
2687 # handle the overridable events
2688 $self->override_events unless $self->is_renewal;
2689 return if $self->bail_out;
2691 if( $self->copy and !$self->transit ) {
2693 $self->editor->search_action_transit_copy(
2694 { target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef }
2700 $self->checkin_handle_circ_start;
2701 return if $self->bail_out;
2703 if (!$dont_change_lost_zero) {
2704 # if this circ is LOST and we are configured to generate overdue
2705 # fines for lost items on checkin (to fill the gap between mark
2706 # lost time and when the fines would have naturally stopped), then
2707 # stop_fines is no longer valid and should be cleared.
2709 # stop_fines will be set again during the handle_fines() stage.
2710 # XXX should this setting come from the copy circ lib (like other
2711 # LOST settings), instead of the circulation circ lib?
2712 if ($stat == OILS_COPY_STATUS_LOST) {
2713 $self->circ->clear_stop_fines if
2714 $U->ou_ancestor_setting_value(
2716 OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2721 # Set stop_fines when claimed never checked out
2722 $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2724 # handle fines for this circ, including overdue gen if needed
2725 $self->handle_fines;
2728 $self->checkin_handle_circ_finish;
2729 return if $self->bail_out;
2730 $self->checkin_changed(1);
2732 } elsif( $self->transit ) {
2733 my $hold_transit = $self->process_received_transit;
2734 $self->checkin_changed(1);
2736 if( $self->bail_out ) {
2737 $self->checkin_flesh_events;
2741 if( my $e = $self->check_checkin_copy_status() ) {
2742 # If the original copy status is special, alert the caller
2743 my $ev = $self->events;
2744 $self->events([$e]);
2745 $self->override_events;
2746 return if $self->bail_out;
2750 if( $hold_transit or
2751 $U->copy_status($self->copy->status)->id
2752 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2755 if( $hold_transit ) {
2756 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2758 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2763 if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2765 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2766 $self->reshelve_copy(1);
2767 $self->cancelled_hold_transit(1);
2768 $self->notify_hold(0); # don't notify for cancelled holds
2769 $self->fake_hold_dest(0);
2770 return if $self->bail_out;
2772 } elsif ($hold and $hold->hold_type eq 'R') {
2774 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2775 $self->notify_hold(0); # No need to notify
2776 $self->fake_hold_dest(0);
2777 $self->noop(1); # Don't try and capture for other holds/transits now
2778 $self->update_copy();
2779 $hold->fulfillment_time('now');
2780 $self->bail_on_events($self->editor->event)
2781 unless $self->editor->update_action_hold_request($hold);
2785 # hold transited to correct location
2786 if($self->fake_hold_dest) {
2787 $hold->pickup_lib($self->circ_lib);
2789 $self->checkin_flesh_events;
2794 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2796 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2797 " that is in-transit, but there is no transit.. repairing");
2798 $self->reshelve_copy(1);
2799 return if $self->bail_out;
2802 if( $self->is_renewal ) {
2803 $self->finish_fines_and_voiding;
2804 return if $self->bail_out;
2805 $self->push_events(OpenILS::Event->new('SUCCESS'));
2809 # ------------------------------------------------------------------------------
2810 # Circulations and transits are now closed where necessary. Now go on to see if
2811 # this copy can fulfill a hold or needs to be routed to a different location
2812 # ------------------------------------------------------------------------------
2814 my $needed_for_something = 0; # formerly "needed_for_hold"
2816 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2818 if (!$self->remote_hold) {
2819 if ($self->use_booking) {
2820 my $potential_hold = $self->hold_capture_is_possible;
2821 my $potential_reservation = $self->reservation_capture_is_possible;
2823 if ($potential_hold and $potential_reservation) {
2824 $logger->info("circulator: item could fulfill either hold or reservation");
2825 $self->push_events(new OpenILS::Event(
2826 "HOLD_RESERVATION_CONFLICT",
2827 "hold" => $potential_hold,
2828 "reservation" => $potential_reservation
2830 return if $self->bail_out;
2831 } elsif ($potential_hold) {
2832 $needed_for_something =
2833 $self->attempt_checkin_hold_capture;
2834 } elsif ($potential_reservation) {
2835 $needed_for_something =
2836 $self->attempt_checkin_reservation_capture;
2839 $needed_for_something = $self->attempt_checkin_hold_capture;
2842 return if $self->bail_out;
2844 unless($needed_for_something) {
2845 my $circ_lib = (ref $self->copy->circ_lib) ?
2846 $self->copy->circ_lib->id : $self->copy->circ_lib;
2848 if( $self->remote_hold ) {
2849 $circ_lib = $self->remote_hold->pickup_lib;
2850 $logger->warn("circulator: Copy ".$self->copy->barcode.
2851 " is on a remote hold's shelf, sending to $circ_lib");
2854 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2856 my $suppress_transit = 0;
2858 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2859 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2860 if($suppress_transit_source && $suppress_transit_source->{value}) {
2861 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2862 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2863 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2864 $suppress_transit = 1;
2869 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2870 # copy is where it needs to be, either for hold or reshelving
2872 $self->checkin_handle_precat();
2873 return if $self->bail_out;
2876 # copy needs to transit "home", or stick here if it's a floating copy
2878 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2879 my $res = $self->editor->json_query(
2881 'evergreen.can_float',
2882 $self->copy->floating->id,
2883 $self->copy->circ_lib,
2888 $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res;
2890 if ($can_float) { # Yep, floating, stick here
2891 $self->checkin_changed(1);
2892 $self->copy->circ_lib( $self->circ_lib );
2895 my $bc = $self->copy->barcode;
2896 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2897 $self->checkin_build_copy_transit($circ_lib);
2898 return if $self->bail_out;
2899 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2903 } else { # no-op checkin
2904 if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2905 my $res = $self->editor->json_query(
2908 'evergreen.can_float',
2909 $self->copy->floating->id,
2910 $self->copy->circ_lib,
2915 if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2916 $self->checkin_changed(1);
2917 $self->copy->circ_lib( $self->circ_lib );
2923 if($self->claims_never_checked_out and
2924 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2926 # the item was not supposed to be checked out to the user and should now be marked as missing
2927 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
2928 $self->copy->status($next_status);
2932 $self->reshelve_copy unless $needed_for_something;
2935 return if $self->bail_out;
2937 unless($self->checkin_changed) {
2939 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2940 my $stat = $U->copy_status($self->copy->status)->id;
2942 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2943 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2944 $self->bail_out(1); # no need to commit anything
2948 $self->push_events(OpenILS::Event->new('SUCCESS'))
2949 unless @{$self->events};
2952 $self->finish_fines_and_voiding;
2954 OpenILS::Utils::Penalty->calculate_penalties(
2955 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2957 $self->checkin_flesh_events;
2961 sub finish_fines_and_voiding {
2963 return unless $self->circ;
2965 return unless $self->backdate or $self->void_overdues;
2967 # void overdues after fine generation to prevent concurrent DB access to overdue billings
2968 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2970 my $evt = $CC->void_or_zero_overdues(
2971 $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
2973 return $self->bail_on_events($evt) if $evt;
2975 # Make sure the circ is open or closed as necessary.
2976 $evt = $U->check_open_xact($self->editor, $self->circ->id);
2977 return $self->bail_on_events($evt) if $evt;
2983 # if a deposit was payed for this item, push the event
2984 sub check_circ_deposit {
2986 return unless $self->circ;
2987 my $deposit = $self->editor->search_money_billing(
2989 xact => $self->circ->id,
2991 }, {idlist => 1})->[0];
2993 $self->push_events(OpenILS::Event->new(
2994 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2999 my $force = $self->force || shift;
3000 my $copy = $self->copy;
3002 my $stat = $U->copy_status($copy->status)->id;
3004 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3007 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3008 $stat != OILS_COPY_STATUS_CATALOGING and
3009 $stat != OILS_COPY_STATUS_IN_TRANSIT and
3010 $stat != $next_status )) {
3012 $copy->status( $next_status );
3014 $self->checkin_changed(1);
3019 # Returns true if the item is at the current location
3020 # because it was transited there for a hold and the
3021 # hold has not been fulfilled
3022 sub checkin_check_holds_shelf {
3024 return 0 unless $self->copy;
3027 $U->copy_status($self->copy->status)->id ==
3028 OILS_COPY_STATUS_ON_HOLDS_SHELF;
3030 # Attempt to clear shelf expired holds for this copy
3031 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3032 if($self->clear_expired);
3034 # find the hold that put us on the holds shelf
3035 my $holds = $self->editor->search_action_hold_request(
3037 current_copy => $self->copy->id,
3038 capture_time => { '!=' => undef },
3039 fulfillment_time => undef,
3040 cancel_time => undef,
3045 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3046 $self->reshelve_copy(1);
3050 my $hold = $$holds[0];
3052 $logger->info("circulator: we found a captured, un-fulfilled hold [".
3053 $hold->id. "] for copy ".$self->copy->barcode);
3055 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3056 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3057 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3058 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3059 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3060 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3061 $self->fake_hold_dest(1);
3067 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3068 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3072 $logger->info("circulator: hold is not for here..");
3073 $self->remote_hold($hold);
3078 sub checkin_handle_precat {
3080 my $copy = $self->copy;
3082 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3083 $copy->status(OILS_COPY_STATUS_CATALOGING);
3084 $self->update_copy();
3085 $self->checkin_changed(1);
3086 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3091 sub checkin_build_copy_transit {
3094 my $copy = $self->copy;
3095 my $transit = Fieldmapper::action::transit_copy->new;
3097 # if we are transiting an item to the shelf shelf, it's a hold transit
3098 if (my $hold = $self->remote_hold) {
3099 $transit = Fieldmapper::action::hold_transit_copy->new;
3100 $transit->hold($hold->id);
3102 # the item is going into transit, remove any shelf-iness
3103 if ($hold->current_shelf_lib or $hold->shelf_time) {
3104 $hold->clear_current_shelf_lib;
3105 $hold->clear_shelf_time;
3106 return $self->bail_on_events($self->editor->event)
3107 unless $self->editor->update_action_hold_request($hold);
3111 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3112 $logger->info("circulator: transiting copy to $dest");
3114 $transit->source($self->circ_lib);
3115 $transit->dest($dest);
3116 $transit->target_copy($copy->id);
3117 $transit->source_send_time('now');
3118 $transit->copy_status( $U->copy_status($copy->status)->id );
3120 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3122 if ($self->remote_hold) {
3123 return $self->bail_on_events($self->editor->event)
3124 unless $self->editor->create_action_hold_transit_copy($transit);
3126 return $self->bail_on_events($self->editor->event)
3127 unless $self->editor->create_action_transit_copy($transit);
3130 # ensure the transit is returned to the caller
3131 $self->transit($transit);
3133 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3135 $self->checkin_changed(1);
3139 sub hold_capture_is_possible {
3141 my $copy = $self->copy;
3143 # we've been explicitly told not to capture any holds
3144 return 0 if $self->capture eq 'nocapture';
3146 # See if this copy can fulfill any holds
3147 my $hold = $holdcode->find_nearest_permitted_hold(
3148 $self->editor, $copy, $self->editor->requestor, 1 # check_only
3150 return undef if ref $hold eq "HASH" and
3151 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3155 sub reservation_capture_is_possible {
3157 my $copy = $self->copy;
3159 # we've been explicitly told not to capture any holds
3160 return 0 if $self->capture eq 'nocapture';
3162 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3163 my $resv = $booking_ses->request(
3164 "open-ils.booking.reservations.could_capture",
3165 $self->editor->authtoken, $copy->barcode
3167 $booking_ses->disconnect;
3168 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3169 $self->push_events($resv);
3175 # returns true if the item was used (or may potentially be used
3176 # in subsequent calls) to capture a hold.
3177 sub attempt_checkin_hold_capture {
3179 my $copy = $self->copy;
3181 # we've been explicitly told not to capture any holds
3182 return 0 if $self->capture eq 'nocapture';
3184 # See if this copy can fulfill any holds
3185 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3186 $self->editor, $copy, $self->editor->requestor );
3189 $logger->debug("circulator: no potential permitted".
3190 "holds found for copy ".$copy->barcode);
3194 if($self->capture ne 'capture') {
3195 # see if this item is in a hold-capture-delay location
3196 my $location = $self->copy->location;
3197 if(!ref($location)) {
3198 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3199 $self->copy->location($location);
3201 if($U->is_true($location->hold_verify)) {
3202 $self->bail_on_events(
3203 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3208 $self->retarget($retarget);
3210 my $suppress_transit = 0;
3211 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3212 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3213 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3214 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3215 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3216 $suppress_transit = 1;
3217 $hold->pickup_lib($self->circ_lib);
3222 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3224 $hold->current_copy($copy->id);
3225 $hold->capture_time('now');
3226 $self->put_hold_on_shelf($hold)
3227 if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3229 # prevent DB errors caused by fetching
3230 # holds from storage, and updating through cstore
3231 $hold->clear_fulfillment_time;
3232 $hold->clear_fulfillment_staff;
3233 $hold->clear_fulfillment_lib;
3234 $hold->clear_expire_time;
3235 $hold->clear_cancel_time;
3236 $hold->clear_prev_check_time unless $hold->prev_check_time;
3238 $self->bail_on_events($self->editor->event)
3239 unless $self->editor->update_action_hold_request($hold);
3241 $self->checkin_changed(1);
3243 return 0 if $self->bail_out;
3245 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3247 if ($hold->hold_type eq 'R') {
3248 $copy->status(OILS_COPY_STATUS_CATALOGING);
3249 $hold->fulfillment_time('now');
3250 $self->noop(1); # Block other transit/hold checks
3251 $self->bail_on_events($self->editor->event)
3252 unless $self->editor->update_action_hold_request($hold);
3254 # This hold was captured in the correct location
3255 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3256 $self->push_events(OpenILS::Event->new('SUCCESS'));
3258 #$self->do_hold_notify($hold->id);
3259 $self->notify_hold($hold->id);
3264 # Hold needs to be picked up elsewhere. Build a hold
3265 # transit and route the item.
3266 $self->checkin_build_hold_transit();
3267 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3268 return 0 if $self->bail_out;
3269 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3272 # make sure we save the copy status
3274 return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3278 sub attempt_checkin_reservation_capture {
3280 my $copy = $self->copy;
3282 # we've been explicitly told not to capture any holds
3283 return 0 if $self->capture eq 'nocapture';
3285 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3286 my $evt = $booking_ses->request(
3287 "open-ils.booking.resources.capture_for_reservation",
3288 $self->editor->authtoken,
3290 1 # don't update copy - we probably have it locked
3292 $booking_ses->disconnect;
3294 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3296 "open-ils.booking.resources.capture_for_reservation " .
3297 "didn't return an event!"
3301 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3302 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3304 # not-transferable is an error event we'll pass on the user
3305 $logger->warn("reservation capture attempted against non-transferable item");
3306 $self->push_events($evt);
3308 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3309 # Re-retrieve copy as reservation capture may have changed
3310 # its status and whatnot.
3312 "circulator: booking capture win on copy " . $self->copy->id
3314 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3316 "circulator: changing copy " . $self->copy->id .
3317 "'s status from " . $self->copy->status . " to " .
3320 $self->copy->status($new_copy_status);
3323 $self->reservation($evt->{"payload"}->{"reservation"});
3325 if (exists $evt->{"payload"}->{"transit"}) {
3329 "org" => $evt->{"payload"}->{"transit"}->dest
3333 $self->checkin_changed(1);
3337 # other results are treated as "nothing to capture"
3341 sub do_hold_notify {
3342 my( $self, $holdid ) = @_;
3344 my $e = new_editor(xact => 1);
3345 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3347 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3348 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3350 $logger->info("circulator: running delayed hold notify process");
3352 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3353 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3355 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3356 hold_id => $holdid, requestor => $self->editor->requestor);
3358 $logger->debug("circulator: built hold notifier");
3360 if(!$notifier->event) {
3362 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3364 my $stat = $notifier->send_email_notify;
3365 if( $stat == '1' ) {
3366 $logger->info("circulator: hold notify succeeded for hold $holdid");
3370 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3373 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3377 sub retarget_holds {
3379 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3380 my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3381 $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3382 # no reason to wait for the return value
3386 sub checkin_build_hold_transit {
3389 my $copy = $self->copy;
3390 my $hold = $self->hold;
3391 my $trans = Fieldmapper::action::hold_transit_copy->new;
3393 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3395 $trans->hold($hold->id);
3396 $trans->source($self->circ_lib);
3397 $trans->dest($hold->pickup_lib);
3398 $trans->source_send_time("now");
3399 $trans->target_copy($copy->id);
3401 # when the copy gets to its destination, it will recover
3402 # this status - put it onto the holds shelf
3403 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3405 return $self->bail_on_events($self->editor->event)
3406 unless $self->editor->create_action_hold_transit_copy($trans);
3411 sub process_received_transit {
3413 my $copy = $self->copy;
3414 my $copyid = $self->copy->id;
3416 my $status_name = $U->copy_status($copy->status)->name;
3417 $logger->debug("circulator: attempting transit receive on ".
3418 "copy $copyid. Copy status is $status_name");
3420 my $transit = $self->transit;
3422 # Check if we are in a transit suppress range
3423 my $suppress_transit = 0;
3424 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3425 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3426 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3427 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3428 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3429 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3430 $suppress_transit = 1;
3431 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3435 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3436 # - this item is in-transit to a different location
3437 # - Or we are capturing holds as transits, so why create a new transit?
3439 my $tid = $transit->id;
3440 my $loc = $self->circ_lib;
3441 my $dest = $transit->dest;
3443 $logger->info("circulator: Fowarding transit on copy which is destined ".
3444 "for a different location. transit=$tid, copy=$copyid, current ".
3445 "location=$loc, destination location=$dest");
3447 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3449 # grab the associated hold object if available
3450 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3451 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3453 return $self->bail_on_events($evt);
3456 # The transit is received, set the receive time
3457 $transit->dest_recv_time('now');
3458 $self->bail_on_events($self->editor->event)
3459 unless $self->editor->update_action_transit_copy($transit);
3461 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3463 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3464 $copy->status( $transit->copy_status );
3465 $self->update_copy();
3466 return if $self->bail_out;
3470 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3473 # hold has arrived at destination, set shelf time
3474 $self->put_hold_on_shelf($hold);
3475 $self->bail_on_events($self->editor->event)
3476 unless $self->editor->update_action_hold_request($hold);
3477 return if $self->bail_out;
3479 $self->notify_hold($hold_transit->hold);
3482 $hold_transit = undef;
3483 $self->cancelled_hold_transit(1);
3484 $self->reshelve_copy(1);
3485 $self->fake_hold_dest(0);
3490 OpenILS::Event->new(
3493 payload => { transit => $transit, holdtransit => $hold_transit } ));
3495 return $hold_transit;
3499 # ------------------------------------------------------------------
3500 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3501 # ------------------------------------------------------------------
3502 sub put_hold_on_shelf {
3503 my($self, $hold) = @_;
3504 $hold->shelf_time('now');
3505 $hold->current_shelf_lib($self->circ_lib);
3506 $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3512 my $reservation = shift;
3513 my $dt_parser = DateTime::Format::ISO8601->new;
3515 my $obj = $reservation ? $self->reservation : $self->circ;
3517 my $lost_bill_opts = $self->lost_bill_options;
3518 my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3519 # first, restore any voided overdues for lost, if needed
3520 if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3521 my $restore_od = $U->ou_ancestor_setting_value(
3522 $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3523 $self->editor) || 0;
3524 $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3528 # next, handle normal overdue generation and apply stop_fines
3529 # XXX reservations don't have stop_fines
3530 # TODO revisit booking_reservation re: stop_fines support
3531 if ($reservation or !$obj->stop_fines) {
3534 # This is a crude check for whether we are in a grace period. The code
3535 # in generate_fines() does a more thorough job, so this exists solely
3536 # as a small optimization, and might be better off removed.
3538 # If we have a grace period
3539 if($obj->can('grace_period')) {
3540 # Parse out the due date
3541 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($obj->due_date) );
3542 # Add the grace period to the due date
3543 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($obj->grace_period));
3544 # Don't generate fines on circs still in grace period
3545 $skip_for_grace = $due_date > DateTime->now;
3547 $CC->generate_fines({circs => [$obj], editor => $self->editor})
3548 unless $skip_for_grace;
3550 if (!$reservation and !$obj->stop_fines) {
3551 $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3552 $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3553 $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3554 $obj->stop_fines_time('now');
3555 $obj->stop_fines_time($self->backdate) if $self->backdate;
3556 $self->editor->update_action_circulation($obj);
3560 # finally, handle voiding of lost item and processing fees
3561 if ($self->needs_lost_bill_handling) {
3562 my $void_cost = $U->ou_ancestor_setting_value(
3563 $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3564 $self->editor) || 0;
3565 my $void_proc_fee = $U->ou_ancestor_setting_value(
3566 $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3567 $self->editor) || 0;
3568 $self->checkin_handle_lost_or_lo_now_found(
3569 $lost_bill_opts->{void_cost_btype},
3570 $lost_bill_opts->{is_longoverdue}) if $void_cost;
3571 $self->checkin_handle_lost_or_lo_now_found(
3572 $lost_bill_opts->{void_fee_btype},
3573 $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3579 sub checkin_handle_circ_start {
3581 my $circ = $self->circ;
3582 my $copy = $self->copy;
3586 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3588 # backdate the circ if necessary
3589 if($self->backdate) {
3590 my $evt = $self->checkin_handle_backdate;
3591 return $self->bail_on_events($evt) if $evt;
3594 # Set the checkin vars since we have the item
3595 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3597 # capture the true scan time for back-dated checkins
3598 $circ->checkin_scan_time('now');
3600 $circ->checkin_staff($self->editor->requestor->id);
3601 $circ->checkin_lib($self->circ_lib);
3602 $circ->checkin_workstation($self->editor->requestor->wsid);
3604 my $circ_lib = (ref $self->copy->circ_lib) ?
3605 $self->copy->circ_lib->id : $self->copy->circ_lib;
3606 my $stat = $U->copy_status($self->copy->status)->id;
3608 if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3609 # we will now handle lost fines, but the copy will retain its 'lost'
3610 # status if it needs to transit home unless lost_immediately_available
3613 # if we decide to also delay fine handling until the item arrives home,
3614 # we will need to call lost fine handling code both when checking items
3615 # in and also when receiving transits
3616 $self->checkin_handle_lost($circ_lib);
3617 } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3618 # same process as above.
3619 $self->checkin_handle_long_overdue($circ_lib);
3620 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3621 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3623 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3624 $self->copy->status($U->copy_status($next_status));
3631 sub checkin_handle_circ_finish {
3633 my $e = $self->editor;
3634 my $circ = $self->circ;
3636 # Do one last check before the final circulation update to see
3637 # if the xact_finish value should be set or not.
3639 # The underlying money.billable_xact may have been updated to
3640 # reflect a change in xact_finish during checkin bills handling,
3641 # however we can't simply refresh the circulation from the DB,
3642 # because other changes may be pending. Instead, reproduce the
3643 # xact_finish check here. It won't hurt to do it again.
3645 my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3646 if ($sum) { # is this test still needed?
3648 my $balance = $sum->balance_owed;
3650 if ($balance == 0) {
3651 $circ->xact_finish('now');
3653 $circ->clear_xact_finish;
3656 $logger->info("circulator: $balance is owed on this circulation");
3659 return $self->bail_on_events($e->event)
3660 unless $e->update_action_circulation($circ);
3665 # ------------------------------------------------------------------
3666 # See if we need to void billings, etc. for lost checkin
3667 # ------------------------------------------------------------------
3668 sub checkin_handle_lost {
3670 my $circ_lib = shift;
3672 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3673 OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3675 $self->lost_bill_options({
3676 circ_lib => $circ_lib,
3677 ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3678 ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3679 ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3680 void_cost_btype => 3,
3684 return $self->checkin_handle_lost_or_longoverdue(
3685 circ_lib => $circ_lib,
3686 max_return => $max_return,
3687 ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3688 ous_use_last_activity => undef # not supported for LOST checkin
3692 # ------------------------------------------------------------------
3693 # See if we need to void billings, etc. for long-overdue checkin
3694 # note: not using constants below since they serve little purpose
3695 # for single-use strings that are descriptive in their own right
3696 # and mostly just complicate debugging.
3697 # ------------------------------------------------------------------
3698 sub checkin_handle_long_overdue {
3700 my $circ_lib = shift;
3702 $logger->info("circulator: processing long-overdue checkin...");
3704 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3705 'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3707 $self->lost_bill_options({
3708 circ_lib => $circ_lib,
3709 ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3710 ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3711 is_longoverdue => 1,
3712 ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3713 void_cost_btype => 10,
3714 void_fee_btype => 11
3717 return $self->checkin_handle_lost_or_longoverdue(
3718 circ_lib => $circ_lib,
3719 max_return => $max_return,
3720 ous_immediately_available => 'circ.longoverdue_immediately_available',
3721 ous_use_last_activity =>
3722 'circ.longoverdue.use_last_activity_date_on_return'
3726 # last billing activity is last payment time, last billing time, or the
3727 # circ due date. If the relevant "use last activity" org unit setting is
3728 # false/unset, then last billing activity is always the due date.
3729 sub get_circ_last_billing_activity {
3731 my $circ_lib = shift;
3732 my $setting = shift;
3733 my $date = $self->circ->due_date;
3735 return $date unless $setting and
3736 $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3738 my $xact = $self->editor->retrieve_money_billable_transaction([
3740 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3743 if ($xact->summary) {
3744 $date = $xact->summary->last_payment_ts ||
3745 $xact->summary->last_billing_ts ||
3746 $self->circ->due_date;
3753 sub checkin_handle_lost_or_longoverdue {
3754 my ($self, %args) = @_;
3756 my $circ = $self->circ;
3757 my $max_return = $args{max_return};
3758 my $circ_lib = $args{circ_lib};
3763 $self->get_circ_last_billing_activity(
3764 $circ_lib, $args{ous_use_last_activity});
3767 my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3768 $tm[5] -= 1 if $tm[5] > 0;
3769 my $due = timelocal(int($tm[1]), int($tm[2]),
3770 int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3773 OpenSRF::Utils->interval_to_seconds($max_return) + int($due);
3775 $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3776 "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3777 "DUE: $due LAST: $last_chance");
3779 $max_return = 0 if $today < $last_chance;
3785 $logger->info("circulator: check-in of lost/lo item exceeds max ".
3786 "return interval. skipping fine/fee voiding, etc.");
3788 } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3790 $logger->info("circulator: check-in of lost/lo item having a balance ".
3791 "of zero, skipping fine/fee voiding and reinstatement.");
3793 } else { # within max-return interval or no interval defined
3795 $logger->info("circulator: check-in of lost/lo item is within the ".
3796 "max return interval (or no interval is defined). Proceeding ".
3797 "with fine/fee voiding, etc.");
3799 $self->needs_lost_bill_handling(1);
3802 if ($circ_lib != $self->circ_lib) {
3803 # if the item is not home, check to see if we want to retain the
3804 # lost/longoverdue status at this point in the process
3806 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib,
3807 $args{ous_immediately_available}, $self->editor) || 0;
3809 if ($immediately_available) {
3810 # item status does not need to be retained, so give it a
3811 # reshelving status as if it were a normal checkin
3812 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3813 $self->copy->status($U->copy_status($next_status));
3816 $logger->info("circulator: leaving lost/longoverdue copy".
3817 " status in place on checkin");
3820 # lost/longoverdue item is home and processed, treat like a normal
3821 # checkin from this point on
3822 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3823 $self->copy->status($U->copy_status($next_status));
3829 sub checkin_handle_backdate {
3832 # ------------------------------------------------------------------
3833 # clean up the backdate for date comparison
3834 # XXX We are currently taking the due-time from the original due-date,
3835 # not the input. Do we need to do this? This certainly interferes with
3836 # backdating of hourly checkouts, but that is likely a very rare case.
3837 # ------------------------------------------------------------------
3838 my $bd = cleanse_ISO8601($self->backdate);
3839 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->circ->due_date));
3840 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3841 $new_date->set_hour($original_date->hour());
3842 $new_date->set_minute($original_date->minute());
3843 if ($new_date >= DateTime->now) {
3844 # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3845 # $self->backdate() autoload handler ignores undef values.
3846 # Clear the backdate manually.
3847 $logger->info("circulator: ignoring future backdate: $new_date");
3848 delete $self->{backdate};
3850 $self->backdate(cleanse_ISO8601($new_date->datetime()));
3857 sub check_checkin_copy_status {
3859 my $copy = $self->copy;
3861 my $status = $U->copy_status($copy->status)->id;
3864 if( $self->new_copy_alerts ||
3865 $status == OILS_COPY_STATUS_AVAILABLE ||
3866 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3867 $status == OILS_COPY_STATUS_IN_PROCESS ||
3868 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3869 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3870 $status == OILS_COPY_STATUS_CATALOGING ||
3871 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3872 $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
3873 $status == OILS_COPY_STATUS_RESHELVING );
3875 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3876 if( $status == OILS_COPY_STATUS_LOST );
3878 return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
3879 if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
3881 return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
3882 if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
3884 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3885 if( $status == OILS_COPY_STATUS_MISSING );
3887 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3892 # --------------------------------------------------------------------------
3893 # On checkin, we need to return as many relevant objects as we can
3894 # --------------------------------------------------------------------------
3895 sub checkin_flesh_events {
3898 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3899 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3900 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3903 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3906 if($self->hold and !$self->hold->cancel_time) {
3907 $hold = $self->hold;
3908 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3912 # update our copy of the circ object and
3913 # flesh the billing summary data
3915 $self->editor->retrieve_action_circulation([
3919 circ => ['billable_transaction'],
3928 # flesh some patron fields before returning
3930 $self->editor->retrieve_actor_user([
3935 au => ['card', 'billing_address', 'mailing_address']
3942 for my $evt (@{$self->events}) {
3945 $payload->{copy} = $U->unflesh_copy($self->copy);
3946 $payload->{volume} = $self->volume;
3947 $payload->{record} = $record,
3948 $payload->{circ} = $self->circ;
3949 $payload->{transit} = $self->transit;
3950 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
3951 $payload->{hold} = $hold;
3952 $payload->{patron} = $self->patron;
3953 $payload->{reservation} = $self->reservation
3954 unless (not $self->reservation or $self->reservation->cancel_time);
3956 $evt->{payload} = $payload;
3961 my( $self, $msg ) = @_;
3962 my $bc = ($self->copy) ? $self->copy->barcode :
3965 my $usr = ($self->patron) ? $self->patron->id : "";
3966 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
3967 ", recipient=$usr, copy=$bc");
3973 $self->log_me("do_renew()");
3975 # Make sure there is an open circ to renew
3976 my $usrid = $self->patron->id if $self->patron;
3977 my $circ = $self->editor->search_action_circulation({
3978 target_copy => $self->copy->id,
3979 xact_finish => undef,
3980 checkin_time => undef,
3981 ($usrid ? (usr => $usrid) : ())
3984 return $self->bail_on_events($self->editor->event) unless $circ;
3986 # A user is not allowed to renew another user's items without permission
3987 unless( $circ->usr eq $self->editor->requestor->id ) {
3988 return $self->bail_on_events($self->editor->events)
3989 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
3992 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
3993 if $circ->renewal_remaining < 1;
3995 # -----------------------------------------------------------------
3997 $self->parent_circ($circ->id);
3998 $self->renewal_remaining( $circ->renewal_remaining - 1 );
4001 # Opac renewal - re-use circ library from original circ (unless told not to)
4002 if($self->opac_renewal) {
4003 unless(defined($opac_renewal_use_circ_lib)) {
4004 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
4005 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4006 $opac_renewal_use_circ_lib = 1;
4009 $opac_renewal_use_circ_lib = 0;
4012 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4015 # Desk renewal - re-use circ library from original circ (unless told not to)
4016 if($self->desk_renewal) {
4017 unless(defined($desk_renewal_use_circ_lib)) {
4018 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4019 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4020 $desk_renewal_use_circ_lib = 1;
4023 $desk_renewal_use_circ_lib = 0;
4026 $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
4029 # Run the fine generator against the old circ
4030 # XXX This seems unnecessary, given that handle_fines runs in do_checkin
4031 # a few lines down. Commenting out, for now.
4032 #$self->handle_fines;
4034 $self->run_renew_permit;
4037 $self->do_checkin();
4038 return if $self->bail_out;
4040 unless( $self->permit_override ) {
4042 return if $self->bail_out;
4043 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
4044 $self->remove_event('ITEM_NOT_CATALOGED');
4047 $self->override_events;
4048 return if $self->bail_out;
4051 $self->do_checkout();
4056 my( $self, $evt ) = @_;
4057 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4058 $logger->debug("circulator: removing event from list: $evt");
4059 my @events = @{$self->events};
4060 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
4065 my( $self, $evt ) = @_;
4066 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4067 return grep { $_->{textcode} eq $evt } @{$self->events};
4071 sub run_renew_permit {
4074 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
4075 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
4076 $self->editor, $self->copy, $self->editor->requestor, 1
4078 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
4081 my $results = $self->run_indb_circ_test;
4082 $self->push_events($self->matrix_test_result_events)
4083 unless $self->circ_test_success;
4087 # XXX: The primary mechanism for storing circ history is now handled
4088 # by tracking real circulation objects instead of bibs in a bucket.
4089 # However, this code is disabled by default and could be useful
4090 # some day, so may as well leave it for now.
4091 sub append_reading_list {
4095 $self->is_checkout and
4101 # verify history is globally enabled and uses the bucket mechanism
4102 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
4103 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
4105 return undef unless $htype and $htype eq 'bucket';
4107 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
4109 # verify the patron wants to retain the hisory
4110 my $setting = $e->search_actor_user_setting(
4111 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
4113 unless($setting and $setting->value) {
4118 my $bkt = $e->search_container_copy_bucket(
4119 {owner => $self->patron->id, btype => 'circ_history'})->[0];
4124 # find the next item position
4125 my $last_item = $e->search_container_copy_bucket_item(
4126 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
4127 $pos = $last_item->pos + 1 if $last_item;
4130 # create the history bucket if necessary
4131 $bkt = Fieldmapper::container::copy_bucket->new;
4132 $bkt->owner($self->patron->id);
4134 $bkt->btype('circ_history');
4136 $e->create_container_copy_bucket($bkt) or return $e->die_event;
4139 my $item = Fieldmapper::container::copy_bucket_item->new;
4141 $item->bucket($bkt->id);
4142 $item->target_copy($self->copy->id);
4145 $e->create_container_copy_bucket_item($item) or return $e->die_event;
4152 sub make_trigger_events {
4154 return unless $self->circ;
4155 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
4156 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
4157 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
4162 sub checkin_handle_lost_or_lo_now_found {
4163 my ($self, $bill_type, $is_longoverdue) = @_;
4165 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4167 $logger->debug("voiding $tag item billings");
4168 my $result = $CC->void_or_zero_bills_of_type($self->editor, $self->circ, $self->copy, $bill_type, "$tag ITEM RETURNED");
4169 $self->bail_on_events($self->editor->event) if ($result);
4172 sub checkin_handle_lost_or_lo_now_found_restore_od {
4174 my $circ_lib = shift;
4175 my $is_longoverdue = shift;
4176 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4178 # ------------------------------------------------------------------
4179 # restore those overdue charges voided when item was set to lost
4180 # ------------------------------------------------------------------
4182 my $ods = $self->editor->search_money_billing([
4184 xact => $self->circ->id,
4188 order_by => {mb => 'billing_ts desc'}
4192 $logger->debug("returning ".scalar(@$ods)." overdue charges pre-$tag");
4193 # Because actual users get up to all kinds of unexpectedness, we
4194 # only recreate up to $circ->max_fine in bills. I know you think
4195 # it wouldn't happen that bills could get created, voided, and
4196 # recreated more than once, but I guaran-damn-tee you that it will
4198 if ($ods && @$ods) {
4199 my $void_amount = 0;
4200 my $void_max = $self->circ->max_fine();
4201 # search for overdues voided the new way (aka "adjusted")
4202 my @billings = map {$_->id()} @$ods;
4203 my $voids = $self->editor->search_money_account_adjustment(
4205 billing => \@billings
4209 map {$void_amount += $_->amount()} @$voids;
4211 # if no adjustments found, assume they were voided the old way (aka "voided")
4212 for my $bill (@$ods) {
4213 if( $U->is_true($bill->voided) ) {
4214 $void_amount += $bill->amount();
4220 ($void_amount < $void_max ? $void_amount : $void_max),
4222 $ods->[0]->billing_type(),
4224 "System: $tag RETURNED - OVERDUES REINSTATED",
4225 $ods->[0]->billing_ts() # date this restoration the same as the last overdue (for possible subsequent fine generation)