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
1126 if ($hold and $hold->usr == $patron->id) {
1127 $self->checkout_is_for_hold(1);
1131 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1133 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF'));
1137 sub do_copy_checks {
1139 my $copy = $self->copy;
1140 return unless $copy;
1142 my $stat = $U->copy_status($copy->status)->id;
1144 # We cannot check out a copy if it is in-transit
1145 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1146 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1149 $self->handle_claims_returned();
1150 return if $self->bail_out;
1152 # no claims returned circ was found, check if there is any open circ
1153 unless( $self->is_renewal ) {
1155 my $circs = $self->editor->search_action_circulation(
1156 { target_copy => $copy->id, checkin_time => undef }
1159 if(my $old_circ = $circs->[0]) { # an open circ was found
1161 my $payload = {copy => $copy};
1163 if($old_circ->usr == $self->patron->id) {
1165 $payload->{old_circ} = $old_circ;
1167 # If there is an open circulation on the checkout item and an auto-renew
1168 # interval is defined, inform the caller that they should go
1169 # ahead and renew the item instead of warning about open circulations.
1171 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
1173 'circ.checkout_auto_renew_age',
1177 if($auto_renew_intvl) {
1178 my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
1179 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
1181 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1182 $payload->{auto_renew} = 1;
1187 return $self->bail_on_events(
1188 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1194 my $LEGACY_CIRC_EVENT_MAP = {
1195 'no_item' => 'ITEM_NOT_CATALOGED',
1196 'actor.usr.barred' => 'PATRON_BARRED',
1197 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1198 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1199 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1200 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1201 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1202 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
1203 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1204 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1205 'config.circ_matrix_test.total_copy_hold_ratio' =>
1206 'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1207 'config.circ_matrix_test.available_copy_hold_ratio' =>
1208 'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1212 # ---------------------------------------------------------------------
1213 # This pushes any patron-related events into the list but does not
1214 # set bail_out for any events
1215 # ---------------------------------------------------------------------
1216 sub run_patron_permit_scripts {
1218 my $patronid = $self->patron->id;
1223 my $results = $self->run_indb_circ_test;
1224 unless($self->circ_test_success) {
1225 my @trimmed_results;
1227 if ($self->is_noncat) {
1228 # no_item result is OK during noncat checkout
1229 @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1233 if ($self->checkout_is_for_hold) {
1234 # if this checkout will fulfill a hold, ignore CIRC blocks
1235 # and rely instead on the (later-checked) FULFILL block
1237 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1238 my $fblock_pens = $self->editor->search_config_standing_penalty(
1239 {name => [@pen_names], block_list => {like => '%CIRC%'}});
1241 for my $res (@$results) {
1242 my $name = $res->{fail_part} || '';
1243 next if grep {$_->name eq $name} @$fblock_pens;
1244 push(@trimmed_results, $res);
1248 # not for hold or noncat
1249 @trimmed_results = @$results;
1253 # update the final set of test results
1254 $self->matrix_test_result(\@trimmed_results);
1256 push @allevents, $self->matrix_test_result_events;
1260 $_->{payload} = $self->copy if
1261 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1264 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1266 $self->push_events(@allevents);
1269 sub matrix_test_result_codes {
1271 map { $_->{"fail_part"} } @{$self->matrix_test_result};
1274 sub matrix_test_result_events {
1277 my $event = new OpenILS::Event(
1278 $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1280 $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1282 } (@{$self->matrix_test_result});
1285 sub run_indb_circ_test {
1287 return $self->matrix_test_result if $self->matrix_test_result;
1289 my $dbfunc = ($self->is_renewal) ?
1290 'action.item_user_renew_test' : 'action.item_user_circ_test';
1292 if( $self->is_precat && $self->request_precat) {
1293 $self->make_precat_copy;
1294 return if $self->bail_out;
1297 my $results = $self->editor->json_query(
1301 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id,
1307 $self->circ_test_success($U->is_true($results->[0]->{success}));
1309 if(my $mp = $results->[0]->{matchpoint}) {
1310 $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1311 $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1312 $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1313 if(defined($results->[0]->{renewals})) {
1314 $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1316 $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1317 if(defined($results->[0]->{grace_period})) {
1318 $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1320 $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1321 if(defined($results->[0]->{hard_due_date})) {
1322 $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1324 # Grab the *last* response for limit_groups, where it is more likely to be filled
1325 $self->limit_groups($results->[-1]->{limit_groups});
1328 return $self->matrix_test_result($results);
1331 # ---------------------------------------------------------------------
1332 # given a use and copy, this will calculate the circulation policy
1333 # parameters. Only works with in-db circ.
1334 # ---------------------------------------------------------------------
1338 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1340 $self->run_indb_circ_test;
1343 circ_test_success => $self->circ_test_success,
1344 failure_events => [],
1345 failure_codes => [],
1346 matchpoint => $self->circ_matrix_matchpoint
1349 unless($self->circ_test_success) {
1350 $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1351 $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1354 if($self->circ_matrix_matchpoint) {
1355 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1356 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1357 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1358 my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1360 my $policy = $self->get_circ_policy(
1361 $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1363 $$results{$_} = $$policy{$_} for keys %$policy;
1369 # ---------------------------------------------------------------------
1370 # Loads the circ policy info for duration, recurring fine, and max
1371 # fine based on the current copy
1372 # ---------------------------------------------------------------------
1373 sub get_circ_policy {
1374 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1377 duration_rule => $duration_rule->name,
1378 recurring_fine_rule => $recurring_fine_rule->name,
1379 max_fine_rule => $max_fine_rule->name,
1380 max_fine => $self->get_max_fine_amount($max_fine_rule),
1381 fine_interval => $recurring_fine_rule->recurrence_interval,
1382 renewal_remaining => $duration_rule->max_renewals,
1383 grace_period => $recurring_fine_rule->grace_period
1386 if($hard_due_date) {
1387 $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1388 $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1391 $policy->{duration_date_ceiling} = undef;
1392 $policy->{duration_date_ceiling_force} = undef;
1395 $policy->{duration} = $duration_rule->shrt
1396 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1397 $policy->{duration} = $duration_rule->normal
1398 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1399 $policy->{duration} = $duration_rule->extended
1400 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1402 $policy->{recurring_fine} = $recurring_fine_rule->low
1403 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1404 $policy->{recurring_fine} = $recurring_fine_rule->normal
1405 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1406 $policy->{recurring_fine} = $recurring_fine_rule->high
1407 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1412 sub get_max_fine_amount {
1414 my $max_fine_rule = shift;
1415 my $max_amount = $max_fine_rule->amount;
1417 # if is_percent is true then the max->amount is
1418 # use as a percentage of the copy price
1419 if ($U->is_true($max_fine_rule->is_percent)) {
1420 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1421 $max_amount = $price * $max_fine_rule->amount / 100;
1423 $U->ou_ancestor_setting_value(
1425 'circ.max_fine.cap_at_price',
1429 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1430 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1438 sub run_copy_permit_scripts {
1440 my $copy = $self->copy || return;
1444 my $results = $self->run_indb_circ_test;
1445 push @allevents, $self->matrix_test_result_events
1446 unless $self->circ_test_success;
1448 # See if this copy has an alert message
1449 my $ae = $self->check_copy_alert();
1450 push( @allevents, $ae ) if $ae;
1452 # uniquify the events
1453 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1454 @allevents = values %hash;
1456 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1458 $self->push_events(@allevents);
1462 sub check_copy_alert {
1465 if ($self->new_copy_alerts) {
1467 push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts
1468 if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1470 push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts
1471 if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1474 $self->bail_out(1) if (!$self->override);
1475 return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1479 return undef if $self->is_renewal;
1480 return OpenILS::Event->new(
1481 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1482 if $self->copy and $self->copy->alert_message;
1488 # --------------------------------------------------------------------------
1489 # If the call is overriding and has permissions to override every collected
1490 # event, the are cleared. Any event that the caller does not have
1491 # permission to override, will be left in the event list and bail_out will
1493 # XXX We need code in here to cancel any holds/transits on copies
1494 # that are being force-checked out
1495 # --------------------------------------------------------------------------
1496 sub override_events {
1498 my @events = @{$self->events};
1499 return unless @events;
1500 my $oargs = $self->override_args;
1502 if(!$self->override) {
1503 return $self->bail_out(1)
1504 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1509 for my $e (@events) {
1510 my $tc = $e->{textcode};
1511 next if $tc eq 'SUCCESS';
1512 if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1513 my $ov = "$tc.override";
1514 $logger->info("circulator: attempting to override event: $ov");
1516 return $self->bail_on_events($self->editor->event)
1517 unless( $self->editor->allowed($ov) );
1519 return $self->bail_out(1);
1525 # --------------------------------------------------------------------------
1526 # If there is an open claimsreturn circ on the requested copy, close the
1527 # circ if overriding, otherwise bail out
1528 # --------------------------------------------------------------------------
1529 sub handle_claims_returned {
1531 my $copy = $self->copy;
1533 my $CR = $self->editor->search_action_circulation(
1535 target_copy => $copy->id,
1536 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1537 checkin_time => undef,
1541 return unless ($CR = $CR->[0]);
1545 # - If the caller has set the override flag, we will check the item in
1546 if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1548 $CR->checkin_time('now');
1549 $CR->checkin_scan_time('now');
1550 $CR->checkin_lib($self->circ_lib);
1551 $CR->checkin_workstation($self->editor->requestor->wsid);
1552 $CR->checkin_staff($self->editor->requestor->id);
1554 $evt = $self->editor->event
1555 unless $self->editor->update_action_circulation($CR);
1558 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1561 $self->bail_on_events($evt) if $evt;
1566 # --------------------------------------------------------------------------
1567 # This performs the checkout
1568 # --------------------------------------------------------------------------
1572 $self->log_me("do_checkout()");
1574 # make sure perms are good if this isn't a renewal
1575 unless( $self->is_renewal ) {
1576 return $self->bail_on_events($self->editor->event)
1577 unless( $self->editor->allowed('COPY_CHECKOUT') );
1580 # verify the permit key
1581 unless( $self->check_permit_key ) {
1582 if( $self->permit_override ) {
1583 return $self->bail_on_events($self->editor->event)
1584 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1586 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1590 # if this is a non-cataloged circ, build the circ and finish
1591 if( $self->is_noncat ) {
1592 $self->checkout_noncat;
1594 OpenILS::Event->new('SUCCESS',
1595 payload => { noncat_circ => $self->circ }));
1599 if( $self->is_precat ) {
1600 $self->make_precat_copy;
1601 return if $self->bail_out;
1603 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1604 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1607 $self->do_copy_checks;
1608 return if $self->bail_out;
1610 $self->run_checkout_scripts();
1611 return if $self->bail_out;
1613 $self->build_checkout_circ_object();
1614 return if $self->bail_out;
1616 my $modify_to_start = $self->booking_adjusted_due_date();
1617 return if $self->bail_out;
1619 $self->apply_modified_due_date($modify_to_start);
1620 return if $self->bail_out;
1622 return $self->bail_on_events($self->editor->event)
1623 unless $self->editor->create_action_circulation($self->circ);
1625 # refresh the circ to force local time zone for now
1626 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1628 if($self->limit_groups) {
1629 $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1632 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1634 return if $self->bail_out;
1636 $self->apply_deposit_fee();
1637 return if $self->bail_out;
1639 $self->handle_checkout_holds();
1640 return if $self->bail_out;
1642 # ------------------------------------------------------------------------------
1643 # Update the patron penalty info in the DB. Run it for permit-overrides
1644 # since the penalties are not updated during the permit phase
1645 # ------------------------------------------------------------------------------
1646 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1648 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1651 if($self->is_renewal) {
1652 # flesh the billing summary for the checked-in circ
1653 $pcirc = $self->editor->retrieve_action_circulation([
1655 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1660 OpenILS::Event->new('SUCCESS',
1662 copy => $U->unflesh_copy($self->copy),
1663 volume => $self->volume,
1664 circ => $self->circ,
1666 holds_fulfilled => $self->fulfilled_holds,
1667 deposit_billing => $self->deposit_billing,
1668 rental_billing => $self->rental_billing,
1669 parent_circ => $pcirc,
1670 patron => ($self->return_patron) ? $self->patron : undef,
1671 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1677 sub apply_deposit_fee {
1679 my $copy = $self->copy;
1681 ($self->is_deposit and not $self->is_deposit_exempt) or
1682 ($self->is_rental and not $self->is_rental_exempt);
1684 return if $self->is_deposit and $self->skip_deposit_fee;
1685 return if $self->is_rental and $self->skip_rental_fee;
1687 my $bill = Fieldmapper::money::billing->new;
1688 my $amount = $copy->deposit_amount;
1692 if($self->is_deposit) {
1693 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1695 $self->deposit_billing($bill);
1697 $billing_type = OILS_BILLING_TYPE_RENTAL;
1699 $self->rental_billing($bill);
1702 $bill->xact($self->circ->id);
1703 $bill->amount($amount);
1704 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1705 $bill->billing_type($billing_type);
1706 $bill->btype($btype);
1707 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1709 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1714 my $copy = $self->copy;
1716 my $stat = $copy->status if ref $copy->status;
1717 my $loc = $copy->location if ref $copy->location;
1718 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1720 $copy->status($stat->id) if $stat;
1721 $copy->location($loc->id) if $loc;
1722 $copy->circ_lib($circ_lib->id) if $circ_lib;
1723 $copy->editor($self->editor->requestor->id);
1724 $copy->edit_date('now');
1725 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1727 return $self->bail_on_events($self->editor->event)
1728 unless $self->editor->update_asset_copy($self->copy);
1730 $copy->status($U->copy_status($copy->status));
1731 $copy->location($loc) if $loc;
1732 $copy->circ_lib($circ_lib) if $circ_lib;
1735 sub update_reservation {
1737 my $reservation = $self->reservation;
1739 my $usr = $reservation->usr;
1740 my $target_rt = $reservation->target_resource_type;
1741 my $target_r = $reservation->target_resource;
1742 my $current_r = $reservation->current_resource;
1744 $reservation->usr($usr->id) if ref $usr;
1745 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1746 $reservation->target_resource($target_r->id) if ref $target_r;
1747 $reservation->current_resource($current_r->id) if ref $current_r;
1749 return $self->bail_on_events($self->editor->event)
1750 unless $self->editor->update_booking_reservation($self->reservation);
1753 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1754 $self->reservation($reservation);
1758 sub bail_on_events {
1759 my( $self, @evts ) = @_;
1760 $self->push_events(@evts);
1764 # ------------------------------------------------------------------------------
1765 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1766 # affects copies that will fulfill holds and CIRC affects all other copies.
1767 # If blocks exists, bail, push Events onto the event pile, and return true.
1768 # ------------------------------------------------------------------------------
1769 sub check_hold_fulfill_blocks {
1772 # With the addition of ignore_proximity in csp, we need to fetch
1773 # the proximity of both the circ_lib and the copy's circ_lib to
1774 # the patron's home_ou.
1775 my ($ou_prox, $copy_prox);
1776 my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1777 $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1778 $ou_prox = -1 unless (defined($ou_prox));
1779 my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1780 if ($copy_ou == $self->circ_lib) {
1781 # Save us the time of an extra query.
1782 $copy_prox = $ou_prox;
1784 $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1785 $copy_prox = -1 unless (defined($copy_prox));
1788 # See if the user has any penalties applied that prevent hold fulfillment
1789 my $pens = $self->editor->json_query({
1790 select => {csp => ['name', 'label']},
1791 from => {ausp => {csp => {}}},
1794 usr => $self->patron->id,
1795 org_unit => $U->get_org_full_path($self->circ_lib),
1797 {stop_date => undef},
1798 {stop_date => {'>' => 'now'}}
1802 block_list => {'like' => '%FULFILL%'},
1804 {ignore_proximity => undef},
1805 {ignore_proximity => {'<' => $ou_prox}},
1806 {ignore_proximity => {'<' => $copy_prox}}
1812 return 0 unless @$pens;
1814 for my $pen (@$pens) {
1815 $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1816 my $event = OpenILS::Event->new($pen->{name});
1817 $event->{desc} = $pen->{label};
1818 $self->push_events($event);
1821 $self->override_events;
1822 return $self->bail_out;
1826 # ------------------------------------------------------------------------------
1827 # When an item is checked out, see if we can fulfill a hold for this patron
1828 # ------------------------------------------------------------------------------
1829 sub handle_checkout_holds {
1831 my $copy = $self->copy;
1832 my $patron = $self->patron;
1834 my $e = $self->editor;
1835 $self->fulfilled_holds([]);
1837 # non-cats can't fulfill a hold
1838 return if $self->is_noncat;
1840 my $hold = $e->search_action_hold_request({
1841 current_copy => $copy->id ,
1842 cancel_time => undef,
1843 fulfillment_time => undef
1846 if($hold and $hold->usr != $patron->id) {
1847 # reset the hold since the copy is now checked out
1849 $logger->info("circulator: un-targeting hold ".$hold->id.
1850 " because copy ".$copy->id." is getting checked out");
1852 $hold->clear_prev_check_time;
1853 $hold->clear_current_copy;
1854 $hold->clear_capture_time;
1855 $hold->clear_shelf_time;
1856 $hold->clear_shelf_expire_time;
1857 $hold->clear_current_shelf_lib;
1859 return $self->bail_on_event($e->event)
1860 unless $e->update_action_hold_request($hold);
1866 $hold = $self->find_related_user_hold($copy, $patron) or return;
1867 $logger->info("circulator: found related hold to fulfill in checkout");
1870 return if $self->check_hold_fulfill_blocks;
1872 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1874 # if the hold was never officially captured, capture it.
1875 $hold->current_copy($copy->id);
1876 $hold->capture_time('now') unless $hold->capture_time;
1877 $hold->fulfillment_time('now');
1878 $hold->fulfillment_staff($e->requestor->id);
1879 $hold->fulfillment_lib($self->circ_lib);
1881 return $self->bail_on_events($e->event)
1882 unless $e->update_action_hold_request($hold);
1884 return $self->fulfilled_holds([$hold->id]);
1888 # ------------------------------------------------------------------------------
1889 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1890 # the patron directly targets the checked out item, see if there is another hold
1891 # for the patron that could be fulfilled by the checked out item. Fulfill the
1892 # oldest hold and only fulfill 1 of them.
1894 # For "another hold":
1896 # First, check for one that the copy matches via hold_copy_map, ensuring that
1897 # *any* hold type that this copy could fill may end up filled.
1899 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1900 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1901 # that are non-requestable to count as capturing those hold types.
1902 # ------------------------------------------------------------------------------
1903 sub find_related_user_hold {
1904 my($self, $copy, $patron) = @_;
1905 my $e = $self->editor;
1907 # holds on precat copies are always copy-level, so this call will
1908 # always return undef. Exit early.
1909 return undef if $self->is_precat;
1911 return undef unless $U->ou_ancestor_setting_value(
1912 $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1914 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1916 select => {ahr => ['id']},
1925 fkey => 'current_copy',
1926 type => 'left' # there may be no current_copy
1933 fulfillment_time => undef,
1934 cancel_time => undef,
1936 {expire_time => undef},
1937 {expire_time => {'>' => 'now'}}
1941 target_copy => $self->copy->id
1945 {id => undef}, # left-join copy may be nonexistent
1946 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1950 order_by => {ahr => {request_time => {direction => 'asc'}}},
1954 my $hold_info = $e->json_query($args)->[0];
1955 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1956 return undef if $U->ou_ancestor_setting_value(
1957 $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1959 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1961 select => {ahr => ['id']},
1966 fkey => 'current_copy',
1967 type => 'left' # there may be no current_copy
1974 fulfillment_time => undef,
1975 cancel_time => undef,
1977 {expire_time => undef},
1978 {expire_time => {'>' => 'now'}}
1985 target => $self->volume->id
1991 target => $self->title->id
1997 {id => undef}, # left-join copy may be nonexistent
1998 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2002 order_by => {ahr => {request_time => {direction => 'asc'}}},
2006 $hold_info = $e->json_query($args)->[0];
2007 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2012 sub run_checkout_scripts {
2025 my $hard_due_date_name;
2027 $self->run_indb_circ_test();
2028 $duration = $self->circ_matrix_matchpoint->duration_rule;
2029 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2030 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2031 $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2033 $duration_name = $duration->name if $duration;
2034 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2037 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2038 return $self->bail_on_events($evt) if ($evt && !$nobail);
2040 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2041 return $self->bail_on_events($evt) if ($evt && !$nobail);
2043 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2044 return $self->bail_on_events($evt) if ($evt && !$nobail);
2046 if($hard_due_date_name) {
2047 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2048 return $self->bail_on_events($evt) if ($evt && !$nobail);
2054 # The item circulates with an unlimited duration
2058 $hard_due_date = undef;
2061 $self->duration_rule($duration);
2062 $self->recurring_fines_rule($recurring);
2063 $self->max_fine_rule($max_fine);
2064 $self->hard_due_date($hard_due_date);
2068 sub build_checkout_circ_object {
2071 my $circ = Fieldmapper::action::circulation->new;
2072 my $duration = $self->duration_rule;
2073 my $max = $self->max_fine_rule;
2074 my $recurring = $self->recurring_fines_rule;
2075 my $hard_due_date = $self->hard_due_date;
2076 my $copy = $self->copy;
2077 my $patron = $self->patron;
2078 my $duration_date_ceiling;
2079 my $duration_date_ceiling_force;
2083 my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2084 $duration_date_ceiling = $policy->{duration_date_ceiling};
2085 $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2087 my $dname = $duration->name;
2088 my $mname = $max->name;
2089 my $rname = $recurring->name;
2091 if($hard_due_date) {
2092 $hdname = $hard_due_date->name;
2095 $logger->debug("circulator: building circulation ".
2096 "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2098 $circ->duration($policy->{duration});
2099 $circ->recurring_fine($policy->{recurring_fine});
2100 $circ->duration_rule($duration->name);
2101 $circ->recurring_fine_rule($recurring->name);
2102 $circ->max_fine_rule($max->name);
2103 $circ->max_fine($policy->{max_fine});
2104 $circ->fine_interval($recurring->recurrence_interval);
2105 $circ->renewal_remaining($duration->max_renewals);
2106 $circ->grace_period($policy->{grace_period});
2110 $logger->info("circulator: copy found with an unlimited circ duration");
2111 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2112 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2113 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2114 $circ->renewal_remaining(0);
2115 $circ->grace_period(0);
2118 $circ->target_copy( $copy->id );
2119 $circ->usr( $patron->id );
2120 $circ->circ_lib( $self->circ_lib );
2121 $circ->workstation($self->editor->requestor->wsid)
2122 if defined $self->editor->requestor->wsid;
2124 # renewals maintain a link to the parent circulation
2125 $circ->parent_circ($self->parent_circ);
2127 if( $self->is_renewal ) {
2128 $circ->opac_renewal('t') if $self->opac_renewal;
2129 $circ->phone_renewal('t') if $self->phone_renewal;
2130 $circ->desk_renewal('t') if $self->desk_renewal;
2131 $circ->renewal_remaining($self->renewal_remaining);
2132 $circ->circ_staff($self->editor->requestor->id);
2136 # if the user provided an overiding checkout time,
2137 # (e.g. the checkout really happened several hours ago), then
2138 # we apply that here. Does this need a perm??
2139 $circ->xact_start(cleanse_ISO8601($self->checkout_time))
2140 if $self->checkout_time;
2142 # if a patron is renewing, 'requestor' will be the patron
2143 $circ->circ_staff($self->editor->requestor->id);
2144 $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2149 sub do_reservation_pickup {
2152 $self->log_me("do_reservation_pickup()");
2154 $self->reservation->pickup_time('now');
2157 $self->reservation->current_resource &&
2158 $U->is_true($self->reservation->target_resource_type->catalog_item)
2160 # We used to try to set $self->copy and $self->patron here,
2161 # but that should already be done.
2163 $self->run_checkout_scripts(1);
2165 my $duration = $self->duration_rule;
2166 my $max = $self->max_fine_rule;
2167 my $recurring = $self->recurring_fines_rule;
2169 if ($duration && $max && $recurring) {
2170 my $policy = $self->get_circ_policy($duration, $recurring, $max);
2172 my $dname = $duration->name;
2173 my $mname = $max->name;
2174 my $rname = $recurring->name;
2176 $logger->debug("circulator: updating reservation ".
2177 "with duration=$dname, maxfine=$mname, recurring=$rname");
2179 $self->reservation->fine_amount($policy->{recurring_fine});
2180 $self->reservation->max_fine($policy->{max_fine});
2181 $self->reservation->fine_interval($recurring->recurrence_interval);
2184 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2185 $self->update_copy();
2188 $self->reservation->fine_amount(
2189 $self->reservation->target_resource_type->fine_amount
2191 $self->reservation->max_fine(
2192 $self->reservation->target_resource_type->max_fine
2194 $self->reservation->fine_interval(
2195 $self->reservation->target_resource_type->fine_interval
2199 $self->update_reservation();
2202 sub do_reservation_return {
2204 my $request = shift;
2206 $self->log_me("do_reservation_return()");
2208 if (not ref $self->reservation) {
2209 my ($reservation, $evt) =
2210 $U->fetch_booking_reservation($self->reservation);
2211 return $self->bail_on_events($evt) if $evt;
2212 $self->reservation($reservation);
2215 $self->handle_fines(1);
2216 $self->reservation->return_time('now');
2217 $self->update_reservation();
2218 $self->reshelve_copy if $self->copy;
2220 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2221 $self->copy( $self->reservation->current_resource->catalog_item );
2225 sub booking_adjusted_due_date {
2227 my $circ = $self->circ;
2228 my $copy = $self->copy;
2230 return undef unless $self->use_booking;
2234 if( $self->due_date ) {
2236 return $self->bail_on_events($self->editor->event)
2237 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2239 $circ->due_date(cleanse_ISO8601($self->due_date));
2243 return unless $copy and $circ->due_date;
2246 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2247 if (@$booking_items) {
2248 my $booking_item = $booking_items->[0];
2249 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2251 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2252 my $shorten_circ_setting = $resource_type->elbow_room ||
2253 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2256 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2257 my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2258 resource => $booking_item->id
2259 , search_start => 'now'
2260 , search_end => $circ->due_date
2261 , fields => { cancel_time => undef, return_time => undef }
2263 $booking_ses->disconnect;
2265 throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2266 return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2268 my $dt_parser = DateTime::Format::ISO8601->new;
2269 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2271 for my $bid (@$bookings) {
2273 my $booking = $self->editor->retrieve_booking_reservation( $bid );
2275 my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2276 my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2278 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2279 if ($booking_start < DateTime->now);
2282 if ($U->is_true($stop_circ_setting)) {
2283 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
2285 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2286 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
2289 # We set the circ duration here only to affect the logic that will
2290 # later (in a DB trigger) mangle the time part of the due date to
2291 # 11:59pm. Having any circ duration that is not a whole number of
2292 # days is enough to prevent the "correction."
2293 my $new_circ_duration = $due_date->epoch - time;
2294 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2295 $circ->duration("$new_circ_duration seconds");
2297 $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2301 return $self->bail_on_events($self->editor->event)
2302 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2308 sub apply_modified_due_date {
2310 my $shift_earlier = shift;
2311 my $circ = $self->circ;
2312 my $copy = $self->copy;
2314 if( $self->due_date ) {
2316 return $self->bail_on_events($self->editor->event)
2317 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2319 $circ->due_date(cleanse_ISO8601($self->due_date));
2323 # if the due_date lands on a day when the location is closed
2324 return unless $copy and $circ->due_date;
2326 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2328 # due-date overlap should be determined by the location the item
2329 # is checked out from, not the owning or circ lib of the item
2330 my $org = $self->circ_lib;
2332 $logger->info("circulator: circ searching for closed date overlap on lib $org".
2333 " with an item due date of ".$circ->due_date );
2335 my $dateinfo = $U->storagereq(
2336 'open-ils.storage.actor.org_unit.closed_date.overlap',
2337 $org, $circ->due_date );
2340 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2341 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2343 # XXX make the behavior more dynamic
2344 # for now, we just push the due date to after the close date
2345 if ($shift_earlier) {
2346 $circ->due_date($dateinfo->{start});
2348 $circ->due_date($dateinfo->{end});
2356 sub create_due_date {
2357 my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2359 # if there is a raw time component (e.g. from postgres),
2360 # turn it into an interval that interval_to_seconds can parse
2361 $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2363 # for now, use the server timezone. TODO: use workstation org timezone
2364 my $due_date = DateTime->now(time_zone => 'local');
2365 $due_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)) if $start_time;
2367 # add the circ duration
2368 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2371 my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2372 if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2373 $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2378 # return ISO8601 time with timezone
2379 return $due_date->strftime('%FT%T%z');
2384 sub make_precat_copy {
2386 my $copy = $self->copy;
2389 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2391 $copy->editor($self->editor->requestor->id);
2392 $copy->edit_date('now');
2393 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2394 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2395 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2396 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2397 $self->update_copy();
2401 $logger->info("circulator: Creating a new precataloged ".
2402 "copy in checkout with barcode " . $self->copy_barcode);
2404 $copy = Fieldmapper::asset::copy->new;
2405 $copy->circ_lib($self->circ_lib);
2406 $copy->creator($self->editor->requestor->id);
2407 $copy->editor($self->editor->requestor->id);
2408 $copy->barcode($self->copy_barcode);
2409 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
2410 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2411 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2413 $copy->dummy_title($self->dummy_title || "");
2414 $copy->dummy_author($self->dummy_author || "");
2415 $copy->dummy_isbn($self->dummy_isbn || "");
2416 $copy->circ_modifier($self->circ_modifier);
2419 # See if we need to override the circ_lib for the copy with a configured circ_lib
2420 # Setting is shortname of the org unit
2421 my $precat_circ_lib = $U->ou_ancestor_setting_value(
2422 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2424 if($precat_circ_lib) {
2425 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2428 $self->bail_on_events($self->editor->event);
2432 $copy->circ_lib($org->id);
2436 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2438 $self->push_events($self->editor->event);
2444 sub checkout_noncat {
2450 my $lib = $self->noncat_circ_lib || $self->circ_lib;
2451 my $count = $self->noncat_count || 1;
2452 my $cotime = cleanse_ISO8601($self->checkout_time) || "";
2454 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2458 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2459 $self->editor->requestor->id,
2467 $self->push_events($evt);
2475 # If a copy goes into transit and is then checked in before the transit checkin
2476 # interval has expired, push an event onto the overridable events list.
2477 sub check_transit_checkin_interval {
2480 # only concerned with in-transit items
2481 return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2483 # no interval, no problem
2484 my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2485 return unless $interval;
2487 # capture the transit so we don't have to fetch it again later during checkin
2489 $self->editor->search_action_transit_copy(
2490 {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2494 # transit from X to X for whatever reason has no min interval
2495 return if $self->transit->source == $self->transit->dest;
2497 my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2498 my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2499 my $horizon = $t_start->add(seconds => $seconds);
2501 # See if we are still within the transit checkin forbidden range
2502 $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK'))
2503 if $horizon > DateTime->now;
2506 # Retarget local holds at checkin
2507 sub checkin_retarget {
2509 return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2510 return unless $self->is_checkin; # Renewals need not be checked
2511 return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2512 return if $self->is_precat; # No holds for precats
2513 return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2514 return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2515 my $status = $U->copy_status($self->copy->status);
2516 return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2517 # Specifically target items that are likely new (by status ID)
2518 return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2519 my $location = $self->copy->location;
2520 if(!ref($location)) {
2521 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2522 $self->copy->location($location);
2524 return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2526 # Fetch holds for the bib
2527 my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2528 $self->editor->authtoken,
2531 capture_time => undef, # No touching captured holds
2532 frozen => 'f', # Don't bother with frozen holds
2533 pickup_lib => $self->circ_lib # Only holds actually here
2536 # Error? Skip the step.
2537 return if exists $result->{"ilsevent"};
2541 foreach my $holdlist (keys %{$result}) {
2542 push @$holds, @{$result->{$holdlist}};
2545 return if scalar(@$holds) == 0; # No holds, no retargeting
2547 # Check for parts on this copy
2548 my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2549 my %parts_hash = ();
2550 %parts_hash = map {$_->part, 1} @$parts if @$parts;
2552 # Loop over holds in request-ish order
2553 # Stage 1: Get them into request-ish order
2554 # Also grab type and target for skipping low hanging ones
2555 $result = $self->editor->json_query({
2556 "select" => { "ahr" => ["id", "hold_type", "target"] },
2557 "from" => { "ahr" => { "au" => { "fkey" => "usr", "join" => "pgt"} } },
2558 "where" => { "id" => $holds },
2560 { "class" => "pgt", "field" => "hold_priority"},
2561 { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2562 { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2563 { "class" => "ahr", "field" => "request_time"}
2568 if (ref $result eq "ARRAY" and scalar @$result) {
2569 foreach (@{$result}) {
2570 # Copy level, but not this copy?
2571 next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2572 and $_->{target} != $self->copy->id);
2573 # Volume level, but not this volume?
2574 next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2575 if(@$parts) { # We have parts?
2577 next if ($_->{hold_type} eq 'T');
2578 # Skip part holds for parts not on this copy
2579 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2581 # No parts, no part holds
2582 next if ($_->{hold_type} eq 'P');
2584 # So much for easy stuff, attempt a retarget!
2585 my $tresult = $U->simplereq(
2586 'open-ils.hold-targeter',
2587 'open-ils.hold-targeter.target',
2588 {hold => $_->{id}, find_copy => $self->copy->id}
2590 if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2591 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2599 $self->log_me("do_checkin()");
2601 return $self->bail_on_events(
2602 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2605 $self->check_transit_checkin_interval;
2606 $self->checkin_retarget;
2608 # the renew code and mk_env should have already found our circulation object
2609 unless( $self->circ ) {
2611 my $circs = $self->editor->search_action_circulation(
2612 { target_copy => $self->copy->id, checkin_time => undef });
2614 $self->circ($$circs[0]);
2616 # for now, just warn if there are multiple open circs on a copy
2617 $logger->warn("circulator: we have ".scalar(@$circs).
2618 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2621 my $stat = $U->copy_status($self->copy->status)->id;
2623 # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2624 # differently if they are already paid for. We need to check for this
2625 # early since overdue generation is potentially affected.
2626 my $dont_change_lost_zero = 0;
2627 if ($stat == OILS_COPY_STATUS_LOST
2628 || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2629 || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2631 # LOST fine settings are controlled by the copy's circ lib, not the the
2633 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2634 $self->copy->circ_lib->id : $self->copy->circ_lib;
2635 $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2636 $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2637 $self->editor) || 0;
2639 if ($dont_change_lost_zero) {
2640 my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2641 $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2644 $self->dont_change_lost_zero($dont_change_lost_zero);
2647 if( $self->checkin_check_holds_shelf() ) {
2648 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2649 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2650 if($self->fake_hold_dest) {
2651 $self->hold->pickup_lib($self->circ_lib);
2653 $self->checkin_flesh_events;
2657 unless( $self->is_renewal ) {
2658 return $self->bail_on_events($self->editor->event)
2659 unless $self->editor->allowed('COPY_CHECKIN');
2662 $self->push_events($self->check_copy_alert());
2663 $self->push_events($self->check_checkin_copy_status());
2665 # if the circ is marked as 'claims returned', add the event to the list
2666 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2667 if ($self->circ and $self->circ->stop_fines
2668 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2670 $self->check_circ_deposit();
2672 # handle the overridable events
2673 $self->override_events unless $self->is_renewal;
2674 return if $self->bail_out;
2676 if( $self->copy and !$self->transit ) {
2678 $self->editor->search_action_transit_copy(
2679 { target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef }
2685 $self->checkin_handle_circ_start;
2686 return if $self->bail_out;
2688 if (!$dont_change_lost_zero) {
2689 # if this circ is LOST and we are configured to generate overdue
2690 # fines for lost items on checkin (to fill the gap between mark
2691 # lost time and when the fines would have naturally stopped), then
2692 # stop_fines is no longer valid and should be cleared.
2694 # stop_fines will be set again during the handle_fines() stage.
2695 # XXX should this setting come from the copy circ lib (like other
2696 # LOST settings), instead of the circulation circ lib?
2697 if ($stat == OILS_COPY_STATUS_LOST) {
2698 $self->circ->clear_stop_fines if
2699 $U->ou_ancestor_setting_value(
2701 OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2706 # Set stop_fines when claimed never checked out
2707 $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2709 # handle fines for this circ, including overdue gen if needed
2710 $self->handle_fines;
2713 $self->checkin_handle_circ_finish;
2714 return if $self->bail_out;
2715 $self->checkin_changed(1);
2717 } elsif( $self->transit ) {
2718 my $hold_transit = $self->process_received_transit;
2719 $self->checkin_changed(1);
2721 if( $self->bail_out ) {
2722 $self->checkin_flesh_events;
2726 if( my $e = $self->check_checkin_copy_status() ) {
2727 # If the original copy status is special, alert the caller
2728 my $ev = $self->events;
2729 $self->events([$e]);
2730 $self->override_events;
2731 return if $self->bail_out;
2735 if( $hold_transit or
2736 $U->copy_status($self->copy->status)->id
2737 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2740 if( $hold_transit ) {
2741 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2743 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2748 if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2750 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2751 $self->reshelve_copy(1);
2752 $self->cancelled_hold_transit(1);
2753 $self->notify_hold(0); # don't notify for cancelled holds
2754 $self->fake_hold_dest(0);
2755 return if $self->bail_out;
2757 } elsif ($hold and $hold->hold_type eq 'R') {
2759 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2760 $self->notify_hold(0); # No need to notify
2761 $self->fake_hold_dest(0);
2762 $self->noop(1); # Don't try and capture for other holds/transits now
2763 $self->update_copy();
2764 $hold->fulfillment_time('now');
2765 $self->bail_on_events($self->editor->event)
2766 unless $self->editor->update_action_hold_request($hold);
2770 # hold transited to correct location
2771 if($self->fake_hold_dest) {
2772 $hold->pickup_lib($self->circ_lib);
2774 $self->checkin_flesh_events;
2779 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2781 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2782 " that is in-transit, but there is no transit.. repairing");
2783 $self->reshelve_copy(1);
2784 return if $self->bail_out;
2787 if( $self->is_renewal ) {
2788 $self->finish_fines_and_voiding;
2789 return if $self->bail_out;
2790 $self->push_events(OpenILS::Event->new('SUCCESS'));
2794 # ------------------------------------------------------------------------------
2795 # Circulations and transits are now closed where necessary. Now go on to see if
2796 # this copy can fulfill a hold or needs to be routed to a different location
2797 # ------------------------------------------------------------------------------
2799 my $needed_for_something = 0; # formerly "needed_for_hold"
2801 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2803 if (!$self->remote_hold) {
2804 if ($self->use_booking) {
2805 my $potential_hold = $self->hold_capture_is_possible;
2806 my $potential_reservation = $self->reservation_capture_is_possible;
2808 if ($potential_hold and $potential_reservation) {
2809 $logger->info("circulator: item could fulfill either hold or reservation");
2810 $self->push_events(new OpenILS::Event(
2811 "HOLD_RESERVATION_CONFLICT",
2812 "hold" => $potential_hold,
2813 "reservation" => $potential_reservation
2815 return if $self->bail_out;
2816 } elsif ($potential_hold) {
2817 $needed_for_something =
2818 $self->attempt_checkin_hold_capture;
2819 } elsif ($potential_reservation) {
2820 $needed_for_something =
2821 $self->attempt_checkin_reservation_capture;
2824 $needed_for_something = $self->attempt_checkin_hold_capture;
2827 return if $self->bail_out;
2829 unless($needed_for_something) {
2830 my $circ_lib = (ref $self->copy->circ_lib) ?
2831 $self->copy->circ_lib->id : $self->copy->circ_lib;
2833 if( $self->remote_hold ) {
2834 $circ_lib = $self->remote_hold->pickup_lib;
2835 $logger->warn("circulator: Copy ".$self->copy->barcode.
2836 " is on a remote hold's shelf, sending to $circ_lib");
2839 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2841 my $suppress_transit = 0;
2843 if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2844 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2845 if($suppress_transit_source && $suppress_transit_source->{value}) {
2846 my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2847 if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2848 $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2849 $suppress_transit = 1;
2854 if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2855 # copy is where it needs to be, either for hold or reshelving
2857 $self->checkin_handle_precat();
2858 return if $self->bail_out;
2861 # copy needs to transit "home", or stick here if it's a floating copy
2863 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2864 my $res = $self->editor->json_query(
2866 'evergreen.can_float',
2867 $self->copy->floating->id,
2868 $self->copy->circ_lib,
2873 $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res;
2875 if ($can_float) { # Yep, floating, stick here
2876 $self->checkin_changed(1);
2877 $self->copy->circ_lib( $self->circ_lib );
2880 my $bc = $self->copy->barcode;
2881 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2882 $self->checkin_build_copy_transit($circ_lib);
2883 return if $self->bail_out;
2884 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2888 } else { # no-op checkin
2889 if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2890 my $res = $self->editor->json_query(
2893 'evergreen.can_float',
2894 $self->copy->floating->id,
2895 $self->copy->circ_lib,
2900 if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2901 $self->checkin_changed(1);
2902 $self->copy->circ_lib( $self->circ_lib );
2908 if($self->claims_never_checked_out and
2909 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2911 # the item was not supposed to be checked out to the user and should now be marked as missing
2912 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
2913 $self->copy->status($next_status);
2917 $self->reshelve_copy unless $needed_for_something;
2920 return if $self->bail_out;
2922 unless($self->checkin_changed) {
2924 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2925 my $stat = $U->copy_status($self->copy->status)->id;
2927 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2928 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2929 $self->bail_out(1); # no need to commit anything
2933 $self->push_events(OpenILS::Event->new('SUCCESS'))
2934 unless @{$self->events};
2937 $self->finish_fines_and_voiding;
2939 OpenILS::Utils::Penalty->calculate_penalties(
2940 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2942 $self->checkin_flesh_events;
2946 sub finish_fines_and_voiding {
2948 return unless $self->circ;
2950 return unless $self->backdate or $self->void_overdues;
2952 # void overdues after fine generation to prevent concurrent DB access to overdue billings
2953 my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2955 my $evt = $CC->void_or_zero_overdues(
2956 $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
2958 return $self->bail_on_events($evt) if $evt;
2960 # Make sure the circ is open or closed as necessary.
2961 $evt = $U->check_open_xact($self->editor, $self->circ->id);
2962 return $self->bail_on_events($evt) if $evt;
2968 # if a deposit was payed for this item, push the event
2969 sub check_circ_deposit {
2971 return unless $self->circ;
2972 my $deposit = $self->editor->search_money_billing(
2974 xact => $self->circ->id,
2976 }, {idlist => 1})->[0];
2978 $self->push_events(OpenILS::Event->new(
2979 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2984 my $force = $self->force || shift;
2985 my $copy = $self->copy;
2987 my $stat = $U->copy_status($copy->status)->id;
2989 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
2992 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2993 $stat != OILS_COPY_STATUS_CATALOGING and
2994 $stat != OILS_COPY_STATUS_IN_TRANSIT and
2995 $stat != $next_status )) {
2997 $copy->status( $next_status );
2999 $self->checkin_changed(1);
3004 # Returns true if the item is at the current location
3005 # because it was transited there for a hold and the
3006 # hold has not been fulfilled
3007 sub checkin_check_holds_shelf {
3009 return 0 unless $self->copy;
3012 $U->copy_status($self->copy->status)->id ==
3013 OILS_COPY_STATUS_ON_HOLDS_SHELF;
3015 # Attempt to clear shelf expired holds for this copy
3016 $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3017 if($self->clear_expired);
3019 # find the hold that put us on the holds shelf
3020 my $holds = $self->editor->search_action_hold_request(
3022 current_copy => $self->copy->id,
3023 capture_time => { '!=' => undef },
3024 fulfillment_time => undef,
3025 cancel_time => undef,
3030 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3031 $self->reshelve_copy(1);
3035 my $hold = $$holds[0];
3037 $logger->info("circulator: we found a captured, un-fulfilled hold [".
3038 $hold->id. "] for copy ".$self->copy->barcode);
3040 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3041 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3042 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3043 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3044 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3045 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3046 $self->fake_hold_dest(1);
3052 if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3053 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3057 $logger->info("circulator: hold is not for here..");
3058 $self->remote_hold($hold);
3063 sub checkin_handle_precat {
3065 my $copy = $self->copy;
3067 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3068 $copy->status(OILS_COPY_STATUS_CATALOGING);
3069 $self->update_copy();
3070 $self->checkin_changed(1);
3071 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3076 sub checkin_build_copy_transit {
3079 my $copy = $self->copy;
3080 my $transit = Fieldmapper::action::transit_copy->new;
3082 # if we are transiting an item to the shelf shelf, it's a hold transit
3083 if (my $hold = $self->remote_hold) {
3084 $transit = Fieldmapper::action::hold_transit_copy->new;
3085 $transit->hold($hold->id);
3087 # the item is going into transit, remove any shelf-iness
3088 if ($hold->current_shelf_lib or $hold->shelf_time) {
3089 $hold->clear_current_shelf_lib;
3090 $hold->clear_shelf_time;
3091 return $self->bail_on_events($self->editor->event)
3092 unless $self->editor->update_action_hold_request($hold);
3096 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3097 $logger->info("circulator: transiting copy to $dest");
3099 $transit->source($self->circ_lib);
3100 $transit->dest($dest);
3101 $transit->target_copy($copy->id);
3102 $transit->source_send_time('now');
3103 $transit->copy_status( $U->copy_status($copy->status)->id );
3105 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3107 if ($self->remote_hold) {
3108 return $self->bail_on_events($self->editor->event)
3109 unless $self->editor->create_action_hold_transit_copy($transit);
3111 return $self->bail_on_events($self->editor->event)
3112 unless $self->editor->create_action_transit_copy($transit);
3115 # ensure the transit is returned to the caller
3116 $self->transit($transit);
3118 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3120 $self->checkin_changed(1);
3124 sub hold_capture_is_possible {
3126 my $copy = $self->copy;
3128 # we've been explicitly told not to capture any holds
3129 return 0 if $self->capture eq 'nocapture';
3131 # See if this copy can fulfill any holds
3132 my $hold = $holdcode->find_nearest_permitted_hold(
3133 $self->editor, $copy, $self->editor->requestor, 1 # check_only
3135 return undef if ref $hold eq "HASH" and
3136 $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3140 sub reservation_capture_is_possible {
3142 my $copy = $self->copy;
3144 # we've been explicitly told not to capture any holds
3145 return 0 if $self->capture eq 'nocapture';
3147 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3148 my $resv = $booking_ses->request(
3149 "open-ils.booking.reservations.could_capture",
3150 $self->editor->authtoken, $copy->barcode
3152 $booking_ses->disconnect;
3153 if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3154 $self->push_events($resv);
3160 # returns true if the item was used (or may potentially be used
3161 # in subsequent calls) to capture a hold.
3162 sub attempt_checkin_hold_capture {
3164 my $copy = $self->copy;
3166 # we've been explicitly told not to capture any holds
3167 return 0 if $self->capture eq 'nocapture';
3169 # See if this copy can fulfill any holds
3170 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3171 $self->editor, $copy, $self->editor->requestor );
3174 $logger->debug("circulator: no potential permitted".
3175 "holds found for copy ".$copy->barcode);
3179 if($self->capture ne 'capture') {
3180 # see if this item is in a hold-capture-delay location
3181 my $location = $self->copy->location;
3182 if(!ref($location)) {
3183 $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3184 $self->copy->location($location);
3186 if($U->is_true($location->hold_verify)) {
3187 $self->bail_on_events(
3188 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3193 $self->retarget($retarget);
3195 my $suppress_transit = 0;
3196 if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3197 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3198 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3199 my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3200 if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3201 $suppress_transit = 1;
3202 $hold->pickup_lib($self->circ_lib);
3207 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3209 $hold->current_copy($copy->id);
3210 $hold->capture_time('now');
3211 $self->put_hold_on_shelf($hold)
3212 if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3214 # prevent DB errors caused by fetching
3215 # holds from storage, and updating through cstore
3216 $hold->clear_fulfillment_time;
3217 $hold->clear_fulfillment_staff;
3218 $hold->clear_fulfillment_lib;
3219 $hold->clear_expire_time;
3220 $hold->clear_cancel_time;
3221 $hold->clear_prev_check_time unless $hold->prev_check_time;
3223 $self->bail_on_events($self->editor->event)
3224 unless $self->editor->update_action_hold_request($hold);
3226 $self->checkin_changed(1);
3228 return 0 if $self->bail_out;
3230 if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3232 if ($hold->hold_type eq 'R') {
3233 $copy->status(OILS_COPY_STATUS_CATALOGING);
3234 $hold->fulfillment_time('now');
3235 $self->noop(1); # Block other transit/hold checks
3236 $self->bail_on_events($self->editor->event)
3237 unless $self->editor->update_action_hold_request($hold);
3239 # This hold was captured in the correct location
3240 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3241 $self->push_events(OpenILS::Event->new('SUCCESS'));
3243 #$self->do_hold_notify($hold->id);
3244 $self->notify_hold($hold->id);
3249 # Hold needs to be picked up elsewhere. Build a hold
3250 # transit and route the item.
3251 $self->checkin_build_hold_transit();
3252 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3253 return 0 if $self->bail_out;
3254 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3257 # make sure we save the copy status
3259 return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3263 sub attempt_checkin_reservation_capture {
3265 my $copy = $self->copy;
3267 # we've been explicitly told not to capture any holds
3268 return 0 if $self->capture eq 'nocapture';
3270 my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3271 my $evt = $booking_ses->request(
3272 "open-ils.booking.resources.capture_for_reservation",
3273 $self->editor->authtoken,
3275 1 # don't update copy - we probably have it locked
3277 $booking_ses->disconnect;
3279 if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3281 "open-ils.booking.resources.capture_for_reservation " .
3282 "didn't return an event!"
3286 $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3287 $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3289 # not-transferable is an error event we'll pass on the user
3290 $logger->warn("reservation capture attempted against non-transferable item");
3291 $self->push_events($evt);
3293 } elsif ($evt->{"textcode"} eq "SUCCESS") {
3294 # Re-retrieve copy as reservation capture may have changed
3295 # its status and whatnot.
3297 "circulator: booking capture win on copy " . $self->copy->id
3299 if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3301 "circulator: changing copy " . $self->copy->id .
3302 "'s status from " . $self->copy->status . " to " .
3305 $self->copy->status($new_copy_status);
3308 $self->reservation($evt->{"payload"}->{"reservation"});
3310 if (exists $evt->{"payload"}->{"transit"}) {
3314 "org" => $evt->{"payload"}->{"transit"}->dest
3318 $self->checkin_changed(1);
3322 # other results are treated as "nothing to capture"
3326 sub do_hold_notify {
3327 my( $self, $holdid ) = @_;
3329 my $e = new_editor(xact => 1);
3330 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3332 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3333 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3335 $logger->info("circulator: running delayed hold notify process");
3337 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3338 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3340 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3341 hold_id => $holdid, requestor => $self->editor->requestor);
3343 $logger->debug("circulator: built hold notifier");
3345 if(!$notifier->event) {
3347 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3349 my $stat = $notifier->send_email_notify;
3350 if( $stat == '1' ) {
3351 $logger->info("circulator: hold notify succeeded for hold $holdid");
3355 $logger->debug("circulator: * hold notify cancelled or failed for hold $holdid");
3358 $logger->info("circulator: Not sending hold notification since the patron has no email address");
3362 sub retarget_holds {
3364 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3365 my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3366 $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3367 # no reason to wait for the return value
3371 sub checkin_build_hold_transit {
3374 my $copy = $self->copy;
3375 my $hold = $self->hold;
3376 my $trans = Fieldmapper::action::hold_transit_copy->new;
3378 $logger->debug("circulator: building hold transit for ".$copy->barcode);
3380 $trans->hold($hold->id);
3381 $trans->source($self->circ_lib);
3382 $trans->dest($hold->pickup_lib);
3383 $trans->source_send_time("now");
3384 $trans->target_copy($copy->id);
3386 # when the copy gets to its destination, it will recover
3387 # this status - put it onto the holds shelf
3388 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3390 return $self->bail_on_events($self->editor->event)
3391 unless $self->editor->create_action_hold_transit_copy($trans);
3396 sub process_received_transit {
3398 my $copy = $self->copy;
3399 my $copyid = $self->copy->id;
3401 my $status_name = $U->copy_status($copy->status)->name;
3402 $logger->debug("circulator: attempting transit receive on ".
3403 "copy $copyid. Copy status is $status_name");
3405 my $transit = $self->transit;
3407 # Check if we are in a transit suppress range
3408 my $suppress_transit = 0;
3409 if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3410 my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ? 'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3411 my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3412 if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3413 my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3414 if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3415 $suppress_transit = 1;
3416 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3420 if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3421 # - this item is in-transit to a different location
3422 # - Or we are capturing holds as transits, so why create a new transit?
3424 my $tid = $transit->id;
3425 my $loc = $self->circ_lib;
3426 my $dest = $transit->dest;
3428 $logger->info("circulator: Fowarding transit on copy which is destined ".
3429 "for a different location. transit=$tid, copy=$copyid, current ".
3430 "location=$loc, destination location=$dest");
3432 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3434 # grab the associated hold object if available
3435 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3436 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3438 return $self->bail_on_events($evt);
3441 # The transit is received, set the receive time
3442 $transit->dest_recv_time('now');
3443 $self->bail_on_events($self->editor->event)
3444 unless $self->editor->update_action_transit_copy($transit);
3446 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3448 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3449 $copy->status( $transit->copy_status );
3450 $self->update_copy();
3451 return if $self->bail_out;
3455 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3458 # hold has arrived at destination, set shelf time
3459 $self->put_hold_on_shelf($hold);
3460 $self->bail_on_events($self->editor->event)
3461 unless $self->editor->update_action_hold_request($hold);
3462 return if $self->bail_out;
3464 $self->notify_hold($hold_transit->hold);
3467 $hold_transit = undef;
3468 $self->cancelled_hold_transit(1);
3469 $self->reshelve_copy(1);
3470 $self->fake_hold_dest(0);
3475 OpenILS::Event->new(
3478 payload => { transit => $transit, holdtransit => $hold_transit } ));
3480 return $hold_transit;
3484 # ------------------------------------------------------------------
3485 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3486 # ------------------------------------------------------------------
3487 sub put_hold_on_shelf {
3488 my($self, $hold) = @_;
3489 $hold->shelf_time('now');
3490 $hold->current_shelf_lib($self->circ_lib);
3491 $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3497 my $reservation = shift;
3498 my $dt_parser = DateTime::Format::ISO8601->new;
3500 my $obj = $reservation ? $self->reservation : $self->circ;
3502 my $lost_bill_opts = $self->lost_bill_options;
3503 my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3504 # first, restore any voided overdues for lost, if needed
3505 if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3506 my $restore_od = $U->ou_ancestor_setting_value(
3507 $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3508 $self->editor) || 0;
3509 $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3513 # next, handle normal overdue generation and apply stop_fines
3514 # XXX reservations don't have stop_fines
3515 # TODO revisit booking_reservation re: stop_fines support
3516 if ($reservation or !$obj->stop_fines) {
3519 # This is a crude check for whether we are in a grace period. The code
3520 # in generate_fines() does a more thorough job, so this exists solely
3521 # as a small optimization, and might be better off removed.
3523 # If we have a grace period
3524 if($obj->can('grace_period')) {
3525 # Parse out the due date
3526 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($obj->due_date) );
3527 # Add the grace period to the due date
3528 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($obj->grace_period));
3529 # Don't generate fines on circs still in grace period
3530 $skip_for_grace = $due_date > DateTime->now;
3532 $CC->generate_fines({circs => [$obj], editor => $self->editor})
3533 unless $skip_for_grace;
3535 if (!$reservation and !$obj->stop_fines) {
3536 $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3537 $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3538 $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3539 $obj->stop_fines_time('now');
3540 $obj->stop_fines_time($self->backdate) if $self->backdate;
3541 $self->editor->update_action_circulation($obj);
3545 # finally, handle voiding of lost item and processing fees
3546 if ($self->needs_lost_bill_handling) {
3547 my $void_cost = $U->ou_ancestor_setting_value(
3548 $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3549 $self->editor) || 0;
3550 my $void_proc_fee = $U->ou_ancestor_setting_value(
3551 $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3552 $self->editor) || 0;
3553 $self->checkin_handle_lost_or_lo_now_found(
3554 $lost_bill_opts->{void_cost_btype},
3555 $lost_bill_opts->{is_longoverdue}) if $void_cost;
3556 $self->checkin_handle_lost_or_lo_now_found(
3557 $lost_bill_opts->{void_fee_btype},
3558 $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3564 sub checkin_handle_circ_start {
3566 my $circ = $self->circ;
3567 my $copy = $self->copy;
3571 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3573 # backdate the circ if necessary
3574 if($self->backdate) {
3575 my $evt = $self->checkin_handle_backdate;
3576 return $self->bail_on_events($evt) if $evt;
3579 # Set the checkin vars since we have the item
3580 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3582 # capture the true scan time for back-dated checkins
3583 $circ->checkin_scan_time('now');
3585 $circ->checkin_staff($self->editor->requestor->id);
3586 $circ->checkin_lib($self->circ_lib);
3587 $circ->checkin_workstation($self->editor->requestor->wsid);
3589 my $circ_lib = (ref $self->copy->circ_lib) ?
3590 $self->copy->circ_lib->id : $self->copy->circ_lib;
3591 my $stat = $U->copy_status($self->copy->status)->id;
3593 if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3594 # we will now handle lost fines, but the copy will retain its 'lost'
3595 # status if it needs to transit home unless lost_immediately_available
3598 # if we decide to also delay fine handling until the item arrives home,
3599 # we will need to call lost fine handling code both when checking items
3600 # in and also when receiving transits
3601 $self->checkin_handle_lost($circ_lib);
3602 } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3603 # same process as above.
3604 $self->checkin_handle_long_overdue($circ_lib);
3605 } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3606 $logger->info("circulator: not updating copy status on checkin because copy is missing");
3608 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3609 $self->copy->status($U->copy_status($next_status));
3616 sub checkin_handle_circ_finish {
3618 my $e = $self->editor;
3619 my $circ = $self->circ;
3621 # Do one last check before the final circulation update to see
3622 # if the xact_finish value should be set or not.
3624 # The underlying money.billable_xact may have been updated to
3625 # reflect a change in xact_finish during checkin bills handling,
3626 # however we can't simply refresh the circulation from the DB,
3627 # because other changes may be pending. Instead, reproduce the
3628 # xact_finish check here. It won't hurt to do it again.
3630 my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3631 if ($sum) { # is this test still needed?
3633 my $balance = $sum->balance_owed;
3635 if ($balance == 0) {
3636 $circ->xact_finish('now');
3638 $circ->clear_xact_finish;
3641 $logger->info("circulator: $balance is owed on this circulation");
3644 return $self->bail_on_events($e->event)
3645 unless $e->update_action_circulation($circ);
3650 # ------------------------------------------------------------------
3651 # See if we need to void billings, etc. for lost checkin
3652 # ------------------------------------------------------------------
3653 sub checkin_handle_lost {
3655 my $circ_lib = shift;
3657 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3658 OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3660 $self->lost_bill_options({
3661 circ_lib => $circ_lib,
3662 ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3663 ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3664 ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3665 void_cost_btype => 3,
3669 return $self->checkin_handle_lost_or_longoverdue(
3670 circ_lib => $circ_lib,
3671 max_return => $max_return,
3672 ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3673 ous_use_last_activity => undef # not supported for LOST checkin
3677 # ------------------------------------------------------------------
3678 # See if we need to void billings, etc. for long-overdue checkin
3679 # note: not using constants below since they serve little purpose
3680 # for single-use strings that are descriptive in their own right
3681 # and mostly just complicate debugging.
3682 # ------------------------------------------------------------------
3683 sub checkin_handle_long_overdue {
3685 my $circ_lib = shift;
3687 $logger->info("circulator: processing long-overdue checkin...");
3689 my $max_return = $U->ou_ancestor_setting_value($circ_lib,
3690 'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3692 $self->lost_bill_options({
3693 circ_lib => $circ_lib,
3694 ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3695 ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3696 is_longoverdue => 1,
3697 ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3698 void_cost_btype => 10,
3699 void_fee_btype => 11
3702 return $self->checkin_handle_lost_or_longoverdue(
3703 circ_lib => $circ_lib,
3704 max_return => $max_return,
3705 ous_immediately_available => 'circ.longoverdue_immediately_available',
3706 ous_use_last_activity =>
3707 'circ.longoverdue.use_last_activity_date_on_return'
3711 # last billing activity is last payment time, last billing time, or the
3712 # circ due date. If the relevant "use last activity" org unit setting is
3713 # false/unset, then last billing activity is always the due date.
3714 sub get_circ_last_billing_activity {
3716 my $circ_lib = shift;
3717 my $setting = shift;
3718 my $date = $self->circ->due_date;
3720 return $date unless $setting and
3721 $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3723 my $xact = $self->editor->retrieve_money_billable_transaction([
3725 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3728 if ($xact->summary) {
3729 $date = $xact->summary->last_payment_ts ||
3730 $xact->summary->last_billing_ts ||
3731 $self->circ->due_date;
3738 sub checkin_handle_lost_or_longoverdue {
3739 my ($self, %args) = @_;
3741 my $circ = $self->circ;
3742 my $max_return = $args{max_return};
3743 my $circ_lib = $args{circ_lib};
3748 $self->get_circ_last_billing_activity(
3749 $circ_lib, $args{ous_use_last_activity});
3752 my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3753 $tm[5] -= 1 if $tm[5] > 0;
3754 my $due = timelocal(int($tm[1]), int($tm[2]),
3755 int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3758 OpenSRF::Utils->interval_to_seconds($max_return) + int($due);
3760 $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3761 "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3762 "DUE: $due LAST: $last_chance");
3764 $max_return = 0 if $today < $last_chance;
3770 $logger->info("circulator: check-in of lost/lo item exceeds max ".
3771 "return interval. skipping fine/fee voiding, etc.");
3773 } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3775 $logger->info("circulator: check-in of lost/lo item having a balance ".
3776 "of zero, skipping fine/fee voiding and reinstatement.");
3778 } else { # within max-return interval or no interval defined
3780 $logger->info("circulator: check-in of lost/lo item is within the ".
3781 "max return interval (or no interval is defined). Proceeding ".
3782 "with fine/fee voiding, etc.");
3784 $self->needs_lost_bill_handling(1);
3787 if ($circ_lib != $self->circ_lib) {
3788 # if the item is not home, check to see if we want to retain the
3789 # lost/longoverdue status at this point in the process
3791 my $immediately_available = $U->ou_ancestor_setting_value($circ_lib,
3792 $args{ous_immediately_available}, $self->editor) || 0;
3794 if ($immediately_available) {
3795 # item status does not need to be retained, so give it a
3796 # reshelving status as if it were a normal checkin
3797 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3798 $self->copy->status($U->copy_status($next_status));
3801 $logger->info("circulator: leaving lost/longoverdue copy".
3802 " status in place on checkin");
3805 # lost/longoverdue item is home and processed, treat like a normal
3806 # checkin from this point on
3807 my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3808 $self->copy->status($U->copy_status($next_status));
3814 sub checkin_handle_backdate {
3817 # ------------------------------------------------------------------
3818 # clean up the backdate for date comparison
3819 # XXX We are currently taking the due-time from the original due-date,
3820 # not the input. Do we need to do this? This certainly interferes with
3821 # backdating of hourly checkouts, but that is likely a very rare case.
3822 # ------------------------------------------------------------------
3823 my $bd = cleanse_ISO8601($self->backdate);
3824 my $original_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->circ->due_date));
3825 my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3826 $new_date->set_hour($original_date->hour());
3827 $new_date->set_minute($original_date->minute());
3828 if ($new_date >= DateTime->now) {
3829 # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3830 # $self->backdate() autoload handler ignores undef values.
3831 # Clear the backdate manually.
3832 $logger->info("circulator: ignoring future backdate: $new_date");
3833 delete $self->{backdate};
3835 $self->backdate(cleanse_ISO8601($new_date->datetime()));
3842 sub check_checkin_copy_status {
3844 my $copy = $self->copy;
3846 my $status = $U->copy_status($copy->status)->id;
3849 if( $self->new_copy_alerts ||
3850 $status == OILS_COPY_STATUS_AVAILABLE ||
3851 $status == OILS_COPY_STATUS_CHECKED_OUT ||
3852 $status == OILS_COPY_STATUS_IN_PROCESS ||
3853 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
3854 $status == OILS_COPY_STATUS_IN_TRANSIT ||
3855 $status == OILS_COPY_STATUS_CATALOGING ||
3856 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
3857 $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
3858 $status == OILS_COPY_STATUS_RESHELVING );
3860 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3861 if( $status == OILS_COPY_STATUS_LOST );
3863 return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
3864 if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
3866 return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
3867 if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
3869 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3870 if( $status == OILS_COPY_STATUS_MISSING );
3872 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3877 # --------------------------------------------------------------------------
3878 # On checkin, we need to return as many relevant objects as we can
3879 # --------------------------------------------------------------------------
3880 sub checkin_flesh_events {
3883 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
3884 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3885 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3888 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3891 if($self->hold and !$self->hold->cancel_time) {
3892 $hold = $self->hold;
3893 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3897 # update our copy of the circ object and
3898 # flesh the billing summary data
3900 $self->editor->retrieve_action_circulation([
3904 circ => ['billable_transaction'],
3913 # flesh some patron fields before returning
3915 $self->editor->retrieve_actor_user([
3920 au => ['card', 'billing_address', 'mailing_address']
3927 for my $evt (@{$self->events}) {
3930 $payload->{copy} = $U->unflesh_copy($self->copy);
3931 $payload->{volume} = $self->volume;
3932 $payload->{record} = $record,
3933 $payload->{circ} = $self->circ;
3934 $payload->{transit} = $self->transit;
3935 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
3936 $payload->{hold} = $hold;
3937 $payload->{patron} = $self->patron;
3938 $payload->{reservation} = $self->reservation
3939 unless (not $self->reservation or $self->reservation->cancel_time);
3941 $evt->{payload} = $payload;
3946 my( $self, $msg ) = @_;
3947 my $bc = ($self->copy) ? $self->copy->barcode :
3950 my $usr = ($self->patron) ? $self->patron->id : "";
3951 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
3952 ", recipient=$usr, copy=$bc");
3958 $self->log_me("do_renew()");
3960 # Make sure there is an open circ to renew
3961 my $usrid = $self->patron->id if $self->patron;
3962 my $circ = $self->editor->search_action_circulation({
3963 target_copy => $self->copy->id,
3964 xact_finish => undef,
3965 checkin_time => undef,
3966 ($usrid ? (usr => $usrid) : ())
3969 return $self->bail_on_events($self->editor->event) unless $circ;
3971 # A user is not allowed to renew another user's items without permission
3972 unless( $circ->usr eq $self->editor->requestor->id ) {
3973 return $self->bail_on_events($self->editor->events)
3974 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
3977 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
3978 if $circ->renewal_remaining < 1;
3980 # -----------------------------------------------------------------
3982 $self->parent_circ($circ->id);
3983 $self->renewal_remaining( $circ->renewal_remaining - 1 );
3986 # Opac renewal - re-use circ library from original circ (unless told not to)
3987 if($self->opac_renewal) {
3988 unless(defined($opac_renewal_use_circ_lib)) {
3989 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
3990 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
3991 $opac_renewal_use_circ_lib = 1;
3994 $opac_renewal_use_circ_lib = 0;
3997 $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4000 # Desk renewal - re-use circ library from original circ (unless told not to)
4001 if($self->desk_renewal) {
4002 unless(defined($desk_renewal_use_circ_lib)) {
4003 my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4004 if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4005 $desk_renewal_use_circ_lib = 1;
4008 $desk_renewal_use_circ_lib = 0;
4011 $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
4014 # Run the fine generator against the old circ
4015 # XXX This seems unnecessary, given that handle_fines runs in do_checkin
4016 # a few lines down. Commenting out, for now.
4017 #$self->handle_fines;
4019 $self->run_renew_permit;
4022 $self->do_checkin();
4023 return if $self->bail_out;
4025 unless( $self->permit_override ) {
4027 return if $self->bail_out;
4028 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
4029 $self->remove_event('ITEM_NOT_CATALOGED');
4032 $self->override_events;
4033 return if $self->bail_out;
4036 $self->do_checkout();
4041 my( $self, $evt ) = @_;
4042 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4043 $logger->debug("circulator: removing event from list: $evt");
4044 my @events = @{$self->events};
4045 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
4050 my( $self, $evt ) = @_;
4051 $evt = (ref $evt) ? $evt->{textcode} : $evt;
4052 return grep { $_->{textcode} eq $evt } @{$self->events};
4056 sub run_renew_permit {
4059 if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
4060 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
4061 $self->editor, $self->copy, $self->editor->requestor, 1
4063 $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
4066 my $results = $self->run_indb_circ_test;
4067 $self->push_events($self->matrix_test_result_events)
4068 unless $self->circ_test_success;
4072 # XXX: The primary mechanism for storing circ history is now handled
4073 # by tracking real circulation objects instead of bibs in a bucket.
4074 # However, this code is disabled by default and could be useful
4075 # some day, so may as well leave it for now.
4076 sub append_reading_list {
4080 $self->is_checkout and
4086 # verify history is globally enabled and uses the bucket mechanism
4087 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
4088 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
4090 return undef unless $htype and $htype eq 'bucket';
4092 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
4094 # verify the patron wants to retain the hisory
4095 my $setting = $e->search_actor_user_setting(
4096 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
4098 unless($setting and $setting->value) {
4103 my $bkt = $e->search_container_copy_bucket(
4104 {owner => $self->patron->id, btype => 'circ_history'})->[0];
4109 # find the next item position
4110 my $last_item = $e->search_container_copy_bucket_item(
4111 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
4112 $pos = $last_item->pos + 1 if $last_item;
4115 # create the history bucket if necessary
4116 $bkt = Fieldmapper::container::copy_bucket->new;
4117 $bkt->owner($self->patron->id);
4119 $bkt->btype('circ_history');
4121 $e->create_container_copy_bucket($bkt) or return $e->die_event;
4124 my $item = Fieldmapper::container::copy_bucket_item->new;
4126 $item->bucket($bkt->id);
4127 $item->target_copy($self->copy->id);
4130 $e->create_container_copy_bucket_item($item) or return $e->die_event;
4137 sub make_trigger_events {
4139 return unless $self->circ;
4140 $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
4141 $U->create_events_for_hook('checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
4142 $U->create_events_for_hook('renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
4147 sub checkin_handle_lost_or_lo_now_found {
4148 my ($self, $bill_type, $is_longoverdue) = @_;
4150 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4152 $logger->debug("voiding $tag item billings");
4153 my $result = $CC->void_or_zero_bills_of_type($self->editor, $self->circ, $self->copy, $bill_type, "$tag ITEM RETURNED");
4154 $self->bail_on_events($self->editor->event) if ($result);
4157 sub checkin_handle_lost_or_lo_now_found_restore_od {
4159 my $circ_lib = shift;
4160 my $is_longoverdue = shift;
4161 my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4163 # ------------------------------------------------------------------
4164 # restore those overdue charges voided when item was set to lost
4165 # ------------------------------------------------------------------
4167 my $ods = $self->editor->search_money_billing([
4169 xact => $self->circ->id,
4173 order_by => {mb => 'billing_ts desc'}
4177 $logger->debug("returning ".scalar(@$ods)." overdue charges pre-$tag");
4178 # Because actual users get up to all kinds of unexpectedness, we
4179 # only recreate up to $circ->max_fine in bills. I know you think
4180 # it wouldn't happen that bills could get created, voided, and
4181 # recreated more than once, but I guaran-damn-tee you that it will
4183 if ($ods && @$ods) {
4184 my $void_amount = 0;
4185 my $void_max = $self->circ->max_fine();
4186 # search for overdues voided the new way (aka "adjusted")
4187 my @billings = map {$_->id()} @$ods;
4188 my $voids = $self->editor->search_money_account_adjustment(
4190 billing => \@billings
4194 map {$void_amount += $_->amount()} @$voids;
4196 # if no adjustments found, assume they were voided the old way (aka "voided")
4197 for my $bill (@$ods) {
4198 if( $U->is_true($bill->voided) ) {
4199 $void_amount += $bill->amount();
4205 ($void_amount < $void_max ? $void_amount : $void_max),
4207 $ods->[0]->billing_type(),
4209 "System: $tag RETURNED - OVERDUES REINSTATED",
4210 $ods->[0]->billing_ts() # date this restoration the same as the last overdue (for possible subsequent fine generation)