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 OpenILS::Const qw/:const/;
9 use OpenILS::Application::AppUtils;
11 my $U = "OpenILS::Application::AppUtils";
15 my $legacy_script_support = 0;
20 my $conf = OpenSRF::Utils::SettingsClient->new;
21 my @pfx2 = ( "apps", "open-ils.circ","app_settings" );
23 $legacy_script_support = $conf->config_value(@pfx2, 'legacy_script_support');
24 $legacy_script_support = ($legacy_script_support and $legacy_script_support =~ /true/i);
26 my $lb = $conf->config_value( @pfx2, 'script_path' );
27 $lb = [ $lb ] unless ref($lb);
30 return unless $legacy_script_support;
32 my @pfx = ( @pfx2, "scripts" );
33 my $p = $conf->config_value( @pfx, 'circ_permit_patron' );
34 my $c = $conf->config_value( @pfx, 'circ_permit_copy' );
35 my $d = $conf->config_value( @pfx, 'circ_duration' );
36 my $f = $conf->config_value( @pfx, 'circ_recurring_fines' );
37 my $m = $conf->config_value( @pfx, 'circ_max_fines' );
38 my $pr = $conf->config_value( @pfx, 'circ_permit_renew' );
40 $logger->error( "Missing circ script(s)" )
41 unless( $p and $c and $d and $f and $m and $pr );
43 $scripts{circ_permit_patron} = $p;
44 $scripts{circ_permit_copy} = $c;
45 $scripts{circ_duration} = $d;
46 $scripts{circ_recurring_fines}= $f;
47 $scripts{circ_max_fines} = $m;
48 $scripts{circ_permit_renew} = $pr;
51 "circulator: Loaded rules scripts for circ: " .
52 "circ permit patron = $p, ".
53 "circ permit copy = $c, ".
54 "circ duration = $d, ".
55 "circ recurring fines = $f, " .
56 "circ max fines = $m, ".
57 "circ renew permit = $pr. ".
59 "legacy script support = ". ($legacy_script_support) ? 'yes' : 'no'
64 __PACKAGE__->register_method(
65 method => "run_method",
66 api_name => "open-ils.circ.checkout.permit",
68 Determines if the given checkout can occur
69 @param authtoken The login session key
70 @param params A trailing hash of named params including
71 barcode : The copy barcode,
72 patron : The patron the checkout is occurring for,
73 renew : true or false - whether or not this is a renewal
74 @return The event that occurred during the permit check.
78 __PACKAGE__->register_method (
79 method => 'run_method',
80 api_name => 'open-ils.circ.checkout.permit.override',
81 signature => q/@see open-ils.circ.checkout.permit/,
85 __PACKAGE__->register_method(
86 method => "run_method",
87 api_name => "open-ils.circ.checkout",
90 @param authtoken The login session key
91 @param params A named hash of params including:
93 barcode If no copy is provided, the copy is retrieved via barcode
94 copyid If no copy or barcode is provide, the copy id will be use
95 patron The patron's id
96 noncat True if this is a circulation for a non-cataloted item
97 noncat_type The non-cataloged type id
98 noncat_circ_lib The location for the noncat circ.
99 precat The item has yet to be cataloged
100 dummy_title The temporary title of the pre-cataloded item
101 dummy_author The temporary authr of the pre-cataloded item
102 Default is the home org of the staff member
103 @return The SUCCESS event on success, any other event depending on the error
106 __PACKAGE__->register_method(
107 method => "run_method",
108 api_name => "open-ils.circ.checkin",
111 Generic super-method for handling all copies
112 @param authtoken The login session key
113 @param params Hash of named parameters including:
114 barcode - The copy barcode
115 force - If true, copies in bad statuses will be checked in and give good statuses
116 noop - don't capture holds or put items into transit
117 void_overdues - void all overdues for the circulation (aka amnesty)
122 __PACKAGE__->register_method(
123 method => "run_method",
124 api_name => "open-ils.circ.checkin.override",
125 signature => q/@see open-ils.circ.checkin/
128 __PACKAGE__->register_method(
129 method => "run_method",
130 api_name => "open-ils.circ.renew.override",
131 signature => q/@see open-ils.circ.renew/,
135 __PACKAGE__->register_method(
136 method => "run_method",
137 api_name => "open-ils.circ.renew",
138 notes => <<" NOTES");
139 PARAMS( authtoken, circ => circ_id );
140 open-ils.circ.renew(login_session, circ_object);
141 Renews the provided circulation. login_session is the requestor of the
142 renewal and if the logged in user is not the same as circ->usr, then
143 the logged in user must have RENEW_CIRC permissions.
146 __PACKAGE__->register_method(
147 method => "run_method",
148 api_name => "open-ils.circ.checkout.full");
149 __PACKAGE__->register_method(
150 method => "run_method",
151 api_name => "open-ils.circ.checkout.full.override");
153 __PACKAGE__->register_method(
154 method => "run_method",
155 api_name => "open-ils.circ.reservation.pickup");
156 __PACKAGE__->register_method(
157 method => "run_method",
158 api_name => "open-ils.circ.reservation.return");
160 __PACKAGE__->register_method(
161 method => "run_method",
162 api_name => "open-ils.circ.checkout.inspect",
164 Returns the circ matrix test result and, on success, the rule set and matrix test object
171 my( $self, $conn, $auth, $args ) = @_;
172 translate_legacy_args($args);
173 my $api = $self->api_name;
176 OpenILS::Application::Circ::Circulator->new($auth, %$args);
178 return circ_events($circulator) if $circulator->bail_out;
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 ((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 },
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 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
203 if (my $copy = $circulator->editor->search_asset_copy({ barcode => $bc, deleted => 'f' })->[0]) { # got a copy
204 $copy->status( $transit->copy_status );
205 $copy->editor($circulator->editor->requestor->id);
206 $copy->edit_date('now');
207 $circulator->editor->update_asset_copy( $copy );
211 $transit->dest_recv_time('now');
212 $circulator->editor->update_action_reservation_transit_copy( $transit );
214 $circulator->editor->commit;
216 #XXX need to return here, with info about the resource/copy and the "put it on the booking shelf" message
218 } else { # no transit, look for an upcoming reservation to capture for
220 my $reservation = $circulator->editor->search_booking_reservation(
222 { current_resource => $res_id_list,
223 pickup_lib => $circulator->circ_lib,
224 cancel_time => undef,
225 capture_time => undef
227 { order_by => { bresv => 'start_time' }, limit => 1 }
231 if ($reservation) { # we have a reservation for which we could capture this resource. wheee!
232 my $res_type = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
233 my $elbow_room = $res_type->elbow_room ||
234 $U->ou_ancestor_setting_value( $circulator->circ_lib, 'circ.booking_reservation.default_elbow_room', $circulator->editor );
237 $reservation = $circulator->editor->search_booking_reservation(
239 { id => $reservation->id, start_time => { '<=' => DateTime->now->add( seconds => interval_to_seconds($elbow_room) )->strftime('%FT%T%z') } },
240 { order_by => { bresv => 'start_time' }, limit => 1 }
245 if ($reservation) { # no elbow room specified, or we still have a reservation within the elbow_room time
246 my $b_ses = OpenSRF::AppSession->create('open-ils.booking');
247 my $result = $b_ses->request(
248 'open-ils.booking.reservations.capture',
249 $auth => $reservation->id
252 if (ref($result) && $result->{ilsevent} == 0) { # captured!
253 #XXX what to return here???
254 return $result; # the booking capture success
256 #XXX how to fail??? Probably, just move on.
266 # --------------------------------------------------------------------------
267 # Go ahead and load the script runner to make sure we have all
268 # of the objects we need
269 # --------------------------------------------------------------------------
270 $circulator->is_res_checkin($circulator->is_checkin(1)) if $api =~ /reservation.return/;
271 $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
273 $circulator->is_renewal(1) if $api =~ /renew/;
274 $circulator->is_checkin(1) if $api =~ /checkin/;
275 $circulator->noop if $circulator->claims_never_checked_out;
277 if($legacy_script_support and not $circulator->is_checkin) {
278 $circulator->mk_script_runner();
279 $circulator->legacy_script_support(1);
280 $circulator->circ_permit_patron($scripts{circ_permit_patron});
281 $circulator->circ_permit_copy($scripts{circ_permit_copy});
282 $circulator->circ_duration($scripts{circ_duration});
283 $circulator->circ_permit_renew($scripts{circ_permit_renew});
284 } elsif (not $circulator->is_res_checkin) { # mk_env cannot work w/ reservation.return
285 $circulator->mk_env();
287 return circ_events($circulator) if $circulator->bail_out;
290 $circulator->override(1) if $api =~ /override/o;
292 if( $api =~ /checkout\.permit/ ) {
293 $circulator->do_permit();
295 } elsif( $api =~ /checkout.full/ ) {
297 # requesting a precat checkout implies that any required
298 # overrides have been performed. Go ahead and re-override.
299 $circulator->skip_permit_key(1);
300 $circulator->override(1) if $circulator->request_precat;
301 $circulator->do_permit();
302 $circulator->is_checkout(1);
303 unless( $circulator->bail_out ) {
304 $circulator->events([]);
305 $circulator->do_checkout();
308 } elsif( $circulator->is_res_checkout ) {
309 $circulator->do_reservation_pickup();
311 } elsif( $api =~ /inspect/ ) {
312 my $data = $circulator->do_inspect();
313 $circulator->editor->rollback;
316 } elsif( $api =~ /checkout/ ) {
317 $circulator->is_checkout(1);
318 $circulator->do_checkout();
320 } elsif( $circulator->is_res_checkin ) {
321 $circulator->do_reservation_return();
322 $circulator->do_checkin() if ($circulator->copy());
323 } elsif( $api =~ /checkin/ ) {
324 $circulator->do_checkin();
326 } elsif( $api =~ /renew/ ) {
327 $circulator->is_renewal(1);
328 $circulator->do_renew();
331 if( $circulator->bail_out ) {
334 # make sure no success event accidentally slip in
336 [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
339 my @e = @{$circulator->events};
340 push( @ee, $_->{textcode} ) for @e;
341 $logger->info("circulator: bailing out with events: @ee");
343 $circulator->editor->rollback;
346 $circulator->editor->commit;
349 $circulator->script_runner->cleanup if $circulator->script_runner;
351 $conn->respond_complete(circ_events($circulator));
353 unless($circulator->bail_out) {
354 $circulator->do_hold_notify($circulator->notify_hold)
355 if $circulator->notify_hold;
356 $circulator->retarget_holds if $circulator->retarget;
357 $circulator->append_reading_list;
358 $circulator->make_trigger_events;
364 my @e = @{$circ->events};
365 # if we have multiple events, SUCCESS should not be one of them;
366 @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
367 return (@e == 1) ? $e[0] : \@e;
371 sub translate_legacy_args {
374 if( $$args{barcode} ) {
375 $$args{copy_barcode} = $$args{barcode};
376 delete $$args{barcode};
379 if( $$args{copyid} ) {
380 $$args{copy_id} = $$args{copyid};
381 delete $$args{copyid};
384 if( $$args{patronid} ) {
385 $$args{patron_id} = $$args{patronid};
386 delete $$args{patronid};
389 if( $$args{patron} and !ref($$args{patron}) ) {
390 $$args{patron_id} = $$args{patron};
391 delete $$args{patron};
395 if( $$args{noncat} ) {
396 $$args{is_noncat} = $$args{noncat};
397 delete $$args{noncat};
400 if( $$args{precat} ) {
401 $$args{is_precat} = $$args{request_precat} = $$args{precat};
402 delete $$args{precat};
408 # --------------------------------------------------------------------------
409 # This package actually manages all of the circulation logic
410 # --------------------------------------------------------------------------
411 package OpenILS::Application::Circ::Circulator;
412 use strict; use warnings;
413 use vars q/$AUTOLOAD/;
415 use OpenILS::Utils::Fieldmapper;
416 use OpenSRF::Utils::Cache;
417 use Digest::MD5 qw(md5_hex);
418 use DateTime::Format::ISO8601;
419 use OpenILS::Utils::PermitHold;
420 use OpenSRF::Utils qw/:datetime/;
421 use OpenSRF::Utils::SettingsClient;
422 use OpenILS::Application::Circ::Holds;
423 use OpenILS::Application::Circ::Transit;
424 use OpenSRF::Utils::Logger qw(:logger);
425 use OpenILS::Utils::CStoreEditor qw/:funcs/;
426 use OpenILS::Application::Circ::ScriptBuilder;
427 use OpenILS::Const qw/:const/;
428 use OpenILS::Utils::Penalty;
429 use OpenILS::Application::Circ::CircCommon;
432 my $holdcode = "OpenILS::Application::Circ::Holds";
433 my $transcode = "OpenILS::Application::Circ::Transit";
439 # --------------------------------------------------------------------------
440 # Add a pile of automagic getter/setter methods
441 # --------------------------------------------------------------------------
442 my @AUTOLOAD_FIELDS = qw/
489 recurring_fines_level
501 cancelled_hold_transit
507 circ_matrix_matchpoint
509 legacy_script_support
519 claims_never_checked_out
526 my $type = ref($self) or die "$self is not an object";
528 my $name = $AUTOLOAD;
531 unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
532 $logger->error("circulator: $type: invalid autoload field: $name");
533 die "$type: invalid autoload field: $name\n"
538 *{"${type}::${name}"} = sub {
541 $s->{$name} = $v if defined $v;
545 return $self->$name($data);
550 my( $class, $auth, %args ) = @_;
551 $class = ref($class) || $class;
552 my $self = bless( {}, $class );
555 $self->editor(new_editor(xact => 1, authtoken => $auth));
557 unless( $self->editor->checkauth ) {
558 $self->bail_on_events($self->editor->event);
562 $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
564 $self->$_($args{$_}) for keys %args;
567 ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
569 # if this is a renewal, default to desk_renewal
570 $self->desk_renewal(1) unless
571 $self->opac_renewal or $self->phone_renewal;
573 $self->capture('') unless $self->capture;
575 unless(%user_groups) {
576 my $gps = $self->editor->retrieve_all_permission_grp_tree;
577 %user_groups = map { $_->id => $_ } @$gps;
584 # --------------------------------------------------------------------------
585 # True if we should discontinue processing
586 # --------------------------------------------------------------------------
588 my( $self, $bool ) = @_;
589 if( defined $bool ) {
590 $logger->info("circulator: BAILING OUT") if $bool;
591 $self->{bail_out} = $bool;
593 return $self->{bail_out};
598 my( $self, @evts ) = @_;
601 $logger->info("circulator: pushing event ".$e->{textcode});
602 push( @{$self->events}, $e ) unless
603 grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
609 return '' if $self->skip_permit_key;
610 my $key = md5_hex( time() . rand() . "$$" );
611 $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
612 return $self->permit_key($key);
615 sub check_permit_key {
617 return 1 if $self->skip_permit_key;
618 my $key = $self->permit_key;
619 return 0 unless $key;
620 my $k = "oils_permit_key_$key";
621 my $one = $self->cache_handle->get_cache($k);
622 $self->cache_handle->delete_cache($k);
623 return ($one) ? 1 : 0;
628 my $e = $self->editor;
630 # --------------------------------------------------------------------------
631 # Grab the fleshed copy
632 # --------------------------------------------------------------------------
633 unless($self->is_noncat) {
637 flesh_fields => {acp => ['location', 'status', 'circ_lib', 'age_protect', 'call_number'], acn => ['record']}
640 $copy = $e->retrieve_asset_copy(
641 [$self->copy_id, $flesh ]) or return $e->event;
643 } elsif( $self->copy_barcode ) {
645 $copy = $e->search_asset_copy(
646 [{barcode => $self->copy_barcode, deleted => 'f'}, $flesh ])->[0];
651 $self->volume($copy->call_number);
652 $self->title($self->volume->record);
653 $self->copy->call_number($self->volume->id);
654 $self->volume->record($self->title->id);
655 $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
656 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
657 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
658 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
661 # We can't renew if there is no copy
662 return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
663 if $self->is_renewal;
668 # --------------------------------------------------------------------------
670 # --------------------------------------------------------------------------
672 if( $self->patron_id ) {
673 $patron = $e->retrieve_actor_user($self->patron_id) or return $e->event;
675 } elsif( $self->patron_barcode ) {
677 my $card = $e->search_actor_card(
678 {barcode => $self->patron_barcode})->[0] or return $e->event;
680 $patron = $e->search_actor_user(
681 {card => $card->id})->[0] or return $e->event;
684 if( my $copy = $self->copy ) {
685 my $circs = $e->search_action_circulation(
686 {target_copy => $copy->id, checkin_time => undef});
688 if( my $circ = $circs->[0] ) {
689 $patron = $e->retrieve_actor_user($circ->usr)
695 return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
696 unless $self->patron($patron) or $self->is_checkin;
699 # --------------------------------------------------------------------------
700 # This builds the script runner environment and fetches most of the
702 # --------------------------------------------------------------------------
703 sub mk_script_runner {
709 qw/copy copy_barcode copy_id patron
710 patron_id patron_barcode volume title editor/;
712 # Translate our objects into the ScriptBuilder args hash
713 $$args{$_} = $self->$_() for @fields;
715 $args->{ignore_user_status} = 1 if $self->is_checkin;
716 $$args{fetch_patron_by_circ_copy} = 1;
717 $$args{fetch_patron_circ_info} = 1 unless $self->is_checkin;
719 if( my $pco = $self->pending_checkouts ) {
720 $logger->info("circulator: we were given a pending checkouts number of $pco");
721 $$args{patronItemsOut} = $pco;
724 # This fetches most of the objects we need
725 $self->script_runner(
726 OpenILS::Application::Circ::ScriptBuilder->build($args));
728 # Now we translate the ScriptBuilder objects back into self
729 $self->$_($$args{$_}) for @fields;
731 my @evts = @{$args->{_events}} if $args->{_events};
733 $logger->debug("circulator: script builder returned events: @evts") if @evts;
737 # Anything besides ASSET_COPY_NOT_FOUND will stop processing
738 if(!$self->is_noncat and
740 $evts[0]->{textcode} eq 'ASSET_COPY_NOT_FOUND') {
744 my @e = grep { $_->{textcode} ne 'ASSET_COPY_NOT_FOUND' } @evts;
745 return $self->bail_on_events(@e);
750 $self->is_precat(1) if $self->copy->call_number == OILS_PRECAT_CALL_NUMBER;
751 if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
752 $self->is_deposit(1) if $U->is_true($self->copy->deposit);
753 $self->is_rental(1) unless $U->is_true($self->copy->deposit);
757 # We can't renew if there is no copy
758 return $self->bail_on_events(@evts) if
759 $self->is_renewal and !$self->copy;
761 # Set some circ-specific flags in the script environment
762 my $evt = "environment";
763 $self->script_runner->insert("$evt.isRenewal", ($self->is_renewal) ? 1 : undef);
765 if( $self->is_noncat ) {
766 $self->script_runner->insert("$evt.isNonCat", 1);
767 $self->script_runner->insert("$evt.nonCatType", $self->noncat_type);
770 if( $self->is_precat ) {
771 $self->script_runner->insert("environment.isPrecat", 1, 1);
774 $self->script_runner->add_path( $_ ) for @$script_libs;
779 # --------------------------------------------------------------------------
780 # Does the circ permit work
781 # --------------------------------------------------------------------------
785 $self->log_me("do_permit()");
787 unless( $self->editor->requestor->id == $self->patron->id ) {
788 return $self->bail_on_events($self->editor->event)
789 unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
792 $self->check_captured_holds();
793 $self->do_copy_checks();
794 return if $self->bail_out;
795 $self->run_patron_permit_scripts();
796 $self->run_copy_permit_scripts()
797 unless $self->is_precat or $self->is_noncat;
798 $self->check_item_deposit_events();
799 $self->override_events();
800 return if $self->bail_out;
802 if($self->is_precat and not $self->request_precat) {
805 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
806 return $self->bail_out(1) unless $self->is_renewal;
810 OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
813 sub check_item_deposit_events {
815 $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy))
816 if $self->is_deposit and not $self->is_deposit_exempt;
817 $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy))
818 if $self->is_rental and not $self->is_rental_exempt;
821 # returns true if the user is not required to pay deposits
822 sub is_deposit_exempt {
824 my $pid = (ref $self->patron->profile) ?
825 $self->patron->profile->id : $self->patron->profile;
826 my $groups = $U->ou_ancestor_setting_value(
827 $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
828 for my $grp (@$groups) {
829 return 1 if $self->is_group_descendant($grp, $pid);
834 # returns true if the user is not required to pay rental fees
835 sub is_rental_exempt {
837 my $pid = (ref $self->patron->profile) ?
838 $self->patron->profile->id : $self->patron->profile;
839 my $groups = $U->ou_ancestor_setting_value(
840 $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
841 for my $grp (@$groups) {
842 return 1 if $self->is_group_descendant($grp, $pid);
847 sub is_group_descendant {
848 my($self, $p_id, $c_id) = @_;
849 return 0 unless defined $p_id and defined $c_id;
850 return 1 if $c_id == $p_id;
851 while(my $grp = $user_groups{$c_id}) {
852 $c_id = $grp->parent;
853 return 0 unless defined $c_id;
854 return 1 if $c_id == $p_id;
859 sub check_captured_holds {
861 my $copy = $self->copy;
862 my $patron = $self->patron;
864 return undef unless $copy;
866 my $s = $U->copy_status($copy->status)->id;
867 return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
868 $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
870 # Item is on the holds shelf, make sure it's going to the right person
871 my $holds = $self->editor->search_action_hold_request(
874 current_copy => $copy->id ,
875 capture_time => { '!=' => undef },
876 cancel_time => undef,
877 fulfillment_time => undef
883 if( $holds and $$holds[0] ) {
884 return undef if $$holds[0]->usr == $patron->id;
887 $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
889 $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF'));
895 my $copy = $self->copy;
898 my $stat = $U->copy_status($copy->status)->id;
900 # We cannot check out a copy if it is in-transit
901 if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
902 return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
905 $self->handle_claims_returned();
906 return if $self->bail_out;
908 # no claims returned circ was found, check if there is any open circ
909 unless( $self->is_renewal ) {
911 my $circs = $self->editor->search_action_circulation(
912 { target_copy => $copy->id, checkin_time => undef }
915 if(my $old_circ = $circs->[0]) { # an open circ was found
917 my $payload = {copy => $copy};
919 if($old_circ->usr == $self->patron->id) {
921 $payload->{old_circ} = $old_circ;
923 # If there is an open circulation on the checkout item and an auto-renew
924 # interval is defined, inform the caller that they should go
925 # ahead and renew the item instead of warning about open circulations.
927 my $auto_renew_intvl = $U->ou_ancestor_setting_value(
928 $self->editor->requestor->ws_ou,
929 'circ.checkout_auto_renew_age',
933 if($auto_renew_intvl) {
934 my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
935 my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
937 if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
938 $payload->{auto_renew} = 1;
943 return $self->bail_on_events(
944 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
950 my $LEGACY_CIRC_EVENT_MAP = {
951 'actor.usr.barred' => 'PATRON_BARRED',
952 'asset.copy.circulate' => 'COPY_CIRC_NOT_ALLOWED',
953 'asset.copy.status' => 'COPY_NOT_AVAILABLE',
954 'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
955 'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
956 'config.circ_matrix_test.max_items_out' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
957 'config.circ_matrix_test.max_overdue' => 'PATRON_EXCEEDS_OVERDUE_COUNT',
958 'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
959 'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
963 # ---------------------------------------------------------------------
964 # This pushes any patron-related events into the list but does not
965 # set bail_out for any events
966 # ---------------------------------------------------------------------
967 sub run_patron_permit_scripts {
969 my $runner = $self->script_runner;
970 my $patronid = $self->patron->id;
974 if(!$self->legacy_script_support) {
976 my $results = $self->run_indb_circ_test;
977 unless($self->circ_test_success) {
978 push(@allevents, OpenILS::Event->new(
979 $LEGACY_CIRC_EVENT_MAP->{$_->{fail_part}} || $_->{fail_part}
985 # ---------------------------------------------------------------------
986 # # Now run the patron permit script
987 # ---------------------------------------------------------------------
988 $runner->load($self->circ_permit_patron);
989 my $result = $runner->run or
990 throw OpenSRF::EX::ERROR ("Circ Permit Patron Script Died: $@");
992 my $patron_events = $result->{events};
994 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
995 my $mask = ($self->is_renewal) ? 'RENEW' : 'CIRC';
996 my $penalties = OpenILS::Utils::Penalty->retrieve_penalties($self->editor, $patronid, $self->circ_lib, $mask);
997 $penalties = $penalties->{fatal_penalties};
999 for my $pen (@$penalties) {
1000 my $event = OpenILS::Event->new($pen->name);
1001 $event->{desc} = $pen->label;
1002 push(@allevents, $event);
1005 push(@allevents, OpenILS::Event->new($_)) for (@$patron_events);
1009 $_->{payload} = $self->copy if
1010 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1013 $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1015 $self->push_events(@allevents);
1018 sub run_indb_circ_test {
1020 return $self->matrix_test_result if $self->matrix_test_result;
1022 my $dbfunc = ($self->is_renewal) ?
1023 'action.item_user_renew_test' : 'action.item_user_circ_test';
1025 my $results = $self->editor->json_query(
1028 $self->editor->requestor->ws_ou,
1029 ($self->is_precat or $self->is_noncat) ? undef : $self->copy->id,
1035 $self->circ_test_success($U->is_true($results->[0]->{success}));
1037 if(my $mp = $results->[0]->{matchpoint}) {
1038 $self->circ_matrix_matchpoint(
1039 $self->editor->retrieve_config_circ_matrix_matchpoint([
1042 flesh_fields => {ccmm =>
1043 ['duration_rule', 'recurring_fine_rule', 'max_fine_rule']}
1049 return $self->matrix_test_result($results);
1052 # ---------------------------------------------------------------------
1053 # given a use and copy, this will calculate the circulation policy
1054 # parameters. Only works with in-db circ.
1055 # ---------------------------------------------------------------------
1059 return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1061 $self->run_indb_circ_test;
1064 circ_test_success => $self->circ_test_success,
1065 failure_events => [],
1066 failure_codes => [],
1069 unless($self->circ_test_success) {
1070 push(@{$results->{failure_codes}},
1071 $_->{fail_part}) for @{$self->matrix_test_result};
1072 push(@{$results->{failure_events}},
1073 $LEGACY_CIRC_EVENT_MAP->{$_->{fail_part}} || $_->{fail_part})
1074 for @{$self->matrix_test_result};
1077 if($self->circ_matrix_matchpoint) {
1078 my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1079 my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1080 my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1082 my $policy = $self->get_circ_policy(
1083 $duration_rule, $recurring_fine_rule, $max_fine_rule);
1085 $$results{$_} = $$policy{$_} for keys %$policy;
1091 # ---------------------------------------------------------------------
1092 # Loads the circ policy info for duration, recurring fine, and max
1093 # fine based on the current copy
1094 # ---------------------------------------------------------------------
1095 sub get_circ_policy {
1096 my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule) = @_;
1099 duration_rule => $duration_rule->name,
1100 recurring_fine_rule => $recurring_fine_rule->name,
1101 max_fine_rule => $max_fine_rule->name,
1102 max_fine => $self->get_max_fine_amount($max_fine_rule),
1103 fine_interval => $recurring_fine_rule->recurrence_interval,
1104 renewal_remaining => $duration_rule->max_renewals
1107 $policy->{duration} = $duration_rule->shrt
1108 if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1109 $policy->{duration} = $duration_rule->normal
1110 if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1111 $policy->{duration} = $duration_rule->extended
1112 if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1114 $policy->{recurring_fine} = $recurring_fine_rule->low
1115 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1116 $policy->{recurring_fine} = $recurring_fine_rule->normal
1117 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1118 $policy->{recurring_fine} = $recurring_fine_rule->high
1119 if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1124 sub get_max_fine_amount {
1126 my $max_fine_rule = shift;
1127 my $max_amount = $max_fine_rule->amount;
1129 # if is_percent is true then the max->amount is
1130 # use as a percentage of the copy price
1131 if ($U->is_true($max_fine_rule->is_percent)) {
1132 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1133 $max_amount = $price * $max_fine_rule->amount / 100;
1135 $U->ou_ancestor_setting_value(
1137 'circ.max_fine.cap_at_price',
1141 my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1142 $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1150 sub run_copy_permit_scripts {
1152 my $copy = $self->copy || return;
1153 my $runner = $self->script_runner;
1157 if(!$self->legacy_script_support) {
1158 my $results = $self->run_indb_circ_test;
1159 unless($self->circ_test_success) {
1160 push(@allevents, OpenILS::Event->new(
1161 $LEGACY_CIRC_EVENT_MAP->{$_->{fail_part}} || $_->{fail_part}
1166 # ---------------------------------------------------------------------
1167 # Capture all of the copy permit events
1168 # ---------------------------------------------------------------------
1169 $runner->load($self->circ_permit_copy);
1170 my $result = $runner->run or
1171 throw OpenSRF::EX::ERROR ("Circ Permit Copy Script Died: $@");
1172 my $copy_events = $result->{events};
1174 # ---------------------------------------------------------------------
1175 # Now collect all of the events together
1176 # ---------------------------------------------------------------------
1177 push( @allevents, OpenILS::Event->new($_)) for @$copy_events;
1180 # See if this copy has an alert message
1181 my $ae = $self->check_copy_alert();
1182 push( @allevents, $ae ) if $ae;
1184 # uniquify the events
1185 my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1186 @allevents = values %hash;
1189 $_->{payload} = $copy if
1190 ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1193 $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1195 $self->push_events(@allevents);
1199 sub check_copy_alert {
1201 return undef if $self->is_renewal;
1202 return OpenILS::Event->new(
1203 'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1204 if $self->copy and $self->copy->alert_message;
1210 # --------------------------------------------------------------------------
1211 # If the call is overriding and has permissions to override every collected
1212 # event, the are cleared. Any event that the caller does not have
1213 # permission to override, will be left in the event list and bail_out will
1215 # XXX We need code in here to cancel any holds/transits on copies
1216 # that are being force-checked out
1217 # --------------------------------------------------------------------------
1218 sub override_events {
1220 my @events = @{$self->events};
1221 return unless @events;
1223 if(!$self->override) {
1224 return $self->bail_out(1)
1225 if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1230 for my $e (@events) {
1231 my $tc = $e->{textcode};
1232 next if $tc eq 'SUCCESS';
1233 my $ov = "$tc.override";
1234 $logger->info("circulator: attempting to override event: $ov");
1236 return $self->bail_on_events($self->editor->event)
1237 unless( $self->editor->allowed($ov) );
1242 # --------------------------------------------------------------------------
1243 # If there is an open claimsreturn circ on the requested copy, close the
1244 # circ if overriding, otherwise bail out
1245 # --------------------------------------------------------------------------
1246 sub handle_claims_returned {
1248 my $copy = $self->copy;
1250 my $CR = $self->editor->search_action_circulation(
1252 target_copy => $copy->id,
1253 stop_fines => OILS_STOP_FINES_CLAIMSRETURNED,
1254 checkin_time => undef,
1258 return unless ($CR = $CR->[0]);
1262 # - If the caller has set the override flag, we will check the item in
1263 if($self->override) {
1265 $CR->checkin_time('now');
1266 $CR->checkin_scan_time('now');
1267 $CR->checkin_lib($self->editor->requestor->ws_ou);
1268 $CR->checkin_workstation($self->editor->requestor->wsid);
1269 $CR->checkin_staff($self->editor->requestor->id);
1271 $evt = $self->editor->event
1272 unless $self->editor->update_action_circulation($CR);
1275 $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1278 $self->bail_on_events($evt) if $evt;
1283 # --------------------------------------------------------------------------
1284 # This performs the checkout
1285 # --------------------------------------------------------------------------
1289 $self->log_me("do_checkout()");
1291 # make sure perms are good if this isn't a renewal
1292 unless( $self->is_renewal ) {
1293 return $self->bail_on_events($self->editor->event)
1294 unless( $self->editor->allowed('COPY_CHECKOUT') );
1297 # verify the permit key
1298 unless( $self->check_permit_key ) {
1299 if( $self->permit_override ) {
1300 return $self->bail_on_events($self->editor->event)
1301 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1303 return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1307 # if this is a non-cataloged circ, build the circ and finish
1308 if( $self->is_noncat ) {
1309 $self->checkout_noncat;
1311 OpenILS::Event->new('SUCCESS',
1312 payload => { noncat_circ => $self->circ }));
1316 if( $self->is_precat ) {
1317 $self->make_precat_copy;
1318 return if $self->bail_out;
1320 } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1321 return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1324 $self->do_copy_checks;
1325 return if $self->bail_out;
1327 $self->run_checkout_scripts();
1328 return if $self->bail_out;
1330 $self->build_checkout_circ_object();
1331 return if $self->bail_out;
1333 my $modify_to_start = $self->booking_adjusted_due_date();
1334 return if $self->bail_out;
1336 $self->apply_modified_due_date($modify_to_start);
1337 return if $self->bail_out;
1339 return $self->bail_on_events($self->editor->event)
1340 unless $self->editor->create_action_circulation($self->circ);
1342 # refresh the circ to force local time zone for now
1343 $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1345 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1347 return if $self->bail_out;
1349 $self->apply_deposit_fee();
1350 return if $self->bail_out;
1352 $self->handle_checkout_holds();
1353 return if $self->bail_out;
1355 # ------------------------------------------------------------------------------
1356 # Update the patron penalty info in the DB. Run it for permit-overrides
1357 # since the penalties are not updated during the permit phase
1358 # ------------------------------------------------------------------------------
1359 OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1361 my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1364 if($self->is_renewal) {
1365 # flesh the billing summary for the checked-in circ
1366 $pcirc = $self->editor->retrieve_action_circulation([
1368 {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1373 OpenILS::Event->new('SUCCESS',
1375 copy => $U->unflesh_copy($self->copy),
1376 circ => $self->circ,
1378 holds_fulfilled => $self->fulfilled_holds,
1379 deposit_billing => $self->deposit_billing,
1380 rental_billing => $self->rental_billing,
1381 parent_circ => $pcirc,
1382 patron => ($self->return_patron) ? $self->patron : undef,
1383 patron_money => $self->editor->retrieve_money_user_summary($self->patron->id)
1389 sub apply_deposit_fee {
1391 my $copy = $self->copy;
1393 ($self->is_deposit and not $self->is_deposit_exempt) or
1394 ($self->is_rental and not $self->is_rental_exempt);
1396 my $bill = Fieldmapper::money::billing->new;
1397 my $amount = $copy->deposit_amount;
1401 if($self->is_deposit) {
1402 $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1404 $self->deposit_billing($bill);
1406 $billing_type = OILS_BILLING_TYPE_RENTAL;
1408 $self->rental_billing($bill);
1411 $bill->xact($self->circ->id);
1412 $bill->amount($amount);
1413 $bill->note(OILS_BILLING_NOTE_SYSTEM);
1414 $bill->billing_type($billing_type);
1415 $bill->btype($btype);
1416 $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1418 $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1423 my $copy = $self->copy;
1425 my $stat = $copy->status if ref $copy->status;
1426 my $loc = $copy->location if ref $copy->location;
1427 my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1429 $copy->status($stat->id) if $stat;
1430 $copy->location($loc->id) if $loc;
1431 $copy->circ_lib($circ_lib->id) if $circ_lib;
1432 $copy->editor($self->editor->requestor->id);
1433 $copy->edit_date('now');
1434 $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1436 return $self->bail_on_events($self->editor->event)
1437 unless $self->editor->update_asset_copy($self->copy);
1439 $copy->status($U->copy_status($copy->status));
1440 $copy->location($loc) if $loc;
1441 $copy->circ_lib($circ_lib) if $circ_lib;
1444 sub update_reservation {
1446 my $reservation = $self->reservation;
1448 my $usr = $reservation->usr;
1449 my $target_rt = $reservation->target_resource_type;
1450 my $target_r = $reservation->target_resource;
1451 my $current_r = $reservation->current_resource;
1453 $reservation->usr($usr->id) if ref $usr;
1454 $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1455 $reservation->target_resource($target_r->id) if ref $target_r;
1456 $reservation->current_resource($current_r->id) if ref $current_r;
1458 return $self->bail_on_events($self->editor->event)
1459 unless $self->editor->update_booking_reservation($self->reservation);
1462 ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1463 $self->reservation($reservation);
1467 sub bail_on_events {
1468 my( $self, @evts ) = @_;
1469 $self->push_events(@evts);
1474 # ------------------------------------------------------------------------------
1475 # When an item is checked out, see if we can fulfill a hold for this patron
1476 # ------------------------------------------------------------------------------
1477 sub handle_checkout_holds {
1479 my $copy = $self->copy;
1480 my $patron = $self->patron;
1482 my $e = $self->editor;
1483 $self->fulfilled_holds([]);
1485 # pre/non-cats can't fulfill a hold
1486 return if $self->is_precat or $self->is_noncat;
1488 my $hold = $e->search_action_hold_request({
1489 current_copy => $copy->id ,
1490 cancel_time => undef,
1491 fulfillment_time => undef,
1493 {expire_time => undef},
1494 {expire_time => {'>' => 'now'}}
1498 if($hold and $hold->usr != $patron->id) {
1499 # reset the hold since the copy is now checked out
1501 $logger->info("circulator: un-targeting hold ".$hold->id.
1502 " because copy ".$copy->id." is getting checked out");
1504 $hold->clear_prev_check_time;
1505 $hold->clear_current_copy;
1506 $hold->clear_capture_time;
1508 return $self->bail_on_event($e->event)
1509 unless $e->update_action_hold_request($hold);
1515 $hold = $self->find_related_user_hold($copy, $patron) or return;
1516 $logger->info("circulator: found related hold to fulfill in checkout");
1519 $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1521 # if the hold was never officially captured, capture it.
1522 $hold->current_copy($copy->id);
1523 $hold->capture_time('now') unless $hold->capture_time;
1524 $hold->fulfillment_time('now');
1525 $hold->fulfillment_staff($e->requestor->id);
1526 $hold->fulfillment_lib($e->requestor->ws_ou);
1528 return $self->bail_on_events($e->event)
1529 unless $e->update_action_hold_request($hold);
1531 $holdcode->delete_hold_copy_maps($e, $hold->id);
1532 return $self->fulfilled_holds([$hold->id]);
1536 # ------------------------------------------------------------------------------
1537 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1538 # the patron directly targets the checked out item, see if there is another hold
1539 # (with hold_type T or V) for the patron that could be fulfilled by the checked
1540 # out item. Fulfill the oldest hold and only fulfill 1 of them.
1541 # ------------------------------------------------------------------------------
1542 sub find_related_user_hold {
1543 my($self, $copy, $patron) = @_;
1544 my $e = $self->editor;
1546 return undef if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
1548 return undef unless $U->ou_ancestor_setting_value(
1549 $e->requestor->ws_ou, 'circ.checkout_fills_related_hold', $e);
1551 # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1553 select => {ahr => ['id']},
1558 fkey => 'current_copy',
1559 type => 'left' # there may be no current_copy
1566 fulfillment_time => undef,
1567 cancel_time => undef,
1569 {expire_time => undef},
1570 {expire_time => {'>' => 'now'}}
1577 target => $self->volume->id
1583 target => $self->title->id
1589 {id => undef}, # left-join copy may be nonexistent
1590 {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1594 order_by => {ahr => {request_time => {direction => 'asc'}}},
1598 my $hold_info = $e->json_query($args)->[0];
1599 return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1604 sub run_checkout_scripts {
1609 my $runner = $self->script_runner;
1618 if(!$self->legacy_script_support) {
1619 $self->run_indb_circ_test();
1620 $duration = $self->circ_matrix_matchpoint->duration_rule;
1621 $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
1622 $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
1626 $runner->load($self->circ_duration);
1628 my $result = $runner->run or
1629 throw OpenSRF::EX::ERROR ("Circ Duration Script Died: $@");
1631 $duration_name = $result->{durationRule};
1632 $recurring_name = $result->{recurringFinesRule};
1633 $max_fine_name = $result->{maxFine};
1636 $duration_name = $duration->name if $duration;
1637 if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
1640 ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
1641 return $self->bail_on_events($evt) if ($evt && !$nobail);
1643 ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
1644 return $self->bail_on_events($evt) if ($evt && !$nobail);
1646 ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
1647 return $self->bail_on_events($evt) if ($evt && !$nobail);
1652 # The item circulates with an unlimited duration
1658 $self->duration_rule($duration);
1659 $self->recurring_fines_rule($recurring);
1660 $self->max_fine_rule($max_fine);
1664 sub build_checkout_circ_object {
1667 my $circ = Fieldmapper::action::circulation->new;
1668 my $duration = $self->duration_rule;
1669 my $max = $self->max_fine_rule;
1670 my $recurring = $self->recurring_fines_rule;
1671 my $copy = $self->copy;
1672 my $patron = $self->patron;
1676 my $policy = $self->get_circ_policy($duration, $recurring, $max);
1678 my $dname = $duration->name;
1679 my $mname = $max->name;
1680 my $rname = $recurring->name;
1682 $logger->debug("circulator: building circulation ".
1683 "with duration=$dname, maxfine=$mname, recurring=$rname");
1685 $circ->duration($policy->{duration});
1686 $circ->recurring_fine($policy->{recurring_fine});
1687 $circ->duration_rule($duration->name);
1688 $circ->recurring_fine_rule($recurring->name);
1689 $circ->max_fine_rule($max->name);
1690 $circ->max_fine($policy->{max_fine});
1691 $circ->fine_interval($recurring->recurrence_interval);
1692 $circ->renewal_remaining($duration->max_renewals);
1696 $logger->info("circulator: copy found with an unlimited circ duration");
1697 $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
1698 $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1699 $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1700 $circ->renewal_remaining(0);
1703 $circ->target_copy( $copy->id );
1704 $circ->usr( $patron->id );
1705 $circ->circ_lib( $self->circ_lib );
1706 $circ->workstation($self->editor->requestor->wsid)
1707 if defined $self->editor->requestor->wsid;
1709 # renewals maintain a link to the parent circulation
1710 $circ->parent_circ($self->parent_circ);
1712 if( $self->is_renewal ) {
1713 $circ->opac_renewal('t') if $self->opac_renewal;
1714 $circ->phone_renewal('t') if $self->phone_renewal;
1715 $circ->desk_renewal('t') if $self->desk_renewal;
1716 $circ->renewal_remaining($self->renewal_remaining);
1717 $circ->circ_staff($self->editor->requestor->id);
1721 # if the user provided an overiding checkout time,
1722 # (e.g. the checkout really happened several hours ago), then
1723 # we apply that here. Does this need a perm??
1724 $circ->xact_start(cleanse_ISO8601($self->checkout_time))
1725 if $self->checkout_time;
1727 # if a patron is renewing, 'requestor' will be the patron
1728 $circ->circ_staff($self->editor->requestor->id);
1729 $circ->due_date( $self->create_due_date($circ->duration) ) if $circ->duration;
1734 sub do_reservation_pickup {
1737 $self->log_me("do_reservation_pickup()");
1739 $self->reservation->pickup_time('now');
1742 $self->reservation->current_resource &&
1743 $self->reservation->current_resource->catalog_item
1745 $self->copy( $self->reservation->current_resource->catalog_item );
1746 $self->patron( $self->reservation->usr );
1747 $self->run_checkout_scripts(1);
1749 my $duration = $self->duration_rule;
1750 my $max = $self->max_fine_rule;
1751 my $recurring = $self->recurring_fines_rule;
1753 if ($duration && $max && $recurring) {
1754 my $policy = $self->get_circ_policy($duration, $recurring, $max);
1756 my $dname = $duration->name;
1757 my $mname = $max->name;
1758 my $rname = $recurring->name;
1760 $logger->debug("circulator: building reservation ".
1761 "with duration=$dname, maxfine=$mname, recurring=$rname");
1763 $self->reservation->fine_amount($policy->{recurring_fine});
1764 $self->reservation->max_fine($policy->{max_fine});
1765 $self->reservation->fine_interval($recurring->recurrence_interval);
1768 $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1769 $self->update_copy();
1772 $self->reservation->fine_amount($self->reservation->fine_amount);
1773 $self->reservation->max_fine($self->reservation->max_fine);
1774 $self->reservation->fine_interval($self->reservation->fine_interval);
1777 $self->update_reservation();
1780 sub do_reservation_return {
1782 my $request = shift;
1784 $self->log_me("do_reservation_return()");
1786 my ($reservation, $evt) = $U->fetch_booking_reservation($self->reservation);
1787 return $self->bail_on_events($evt) if $evt;
1789 $self->reservation( $reservation );
1790 $self->generate_fines(1);
1791 $self->reservation->return_time('now');
1792 $self->update_reservation();
1794 if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
1795 $self->copy( $self->reservation->current_resource->catalog_item );
1799 sub booking_adjusted_due_date {
1801 my $circ = $self->circ;
1802 my $copy = $self->copy;
1807 if( $self->due_date ) {
1809 return $self->bail_on_events($self->editor->event)
1810 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
1812 $circ->due_date(cleanse_ISO8601($self->due_date));
1816 return unless $copy and $circ->due_date;
1819 my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
1820 if (@$booking_items) {
1821 my $booking_item = $booking_items->[0];
1822 my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
1824 my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
1825 my $shorten_circ_setting = $resource_type->elbow_room ||
1826 $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
1829 my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
1830 my $bookings = $booking_ses->request(
1831 'open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken,
1832 { resource => $booking_item->id, search_start => 'now', search_end => $circ->due_date }
1834 $booking_ses->disconnect;
1836 my $dt_parser = DateTime::Format::ISO8601->new;
1837 my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
1839 for my $bid (@$bookings) {
1841 my $booking = $self->editor->retrieve_booking_reservation( $bid );
1843 my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
1844 my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
1846 return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
1847 if ($booking_start < DateTime->now);
1850 if ($U->is_true($stop_circ_setting)) {
1851 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') );
1853 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
1854 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now);
1857 # We set the circ duration here only to affect the logic that will
1858 # later (in a DB trigger) mangle the time part of the due date to
1859 # 11:59pm. Having any circ duration that is not a whole number of
1860 # days is enough to prevent the "correction."
1861 my $new_circ_duration = $due_date->epoch - time;
1862 $new_circ_duration++ if $new_circ_duration % 86400 == 0;
1863 $circ->duration("$new_circ_duration seconds");
1865 $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
1869 return $self->bail_on_events($self->editor->event)
1870 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
1876 sub apply_modified_due_date {
1878 my $shift_earlier = shift;
1879 my $circ = $self->circ;
1880 my $copy = $self->copy;
1882 if( $self->due_date ) {
1884 return $self->bail_on_events($self->editor->event)
1885 unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
1887 $circ->due_date(cleanse_ISO8601($self->due_date));
1891 # if the due_date lands on a day when the location is closed
1892 return unless $copy and $circ->due_date;
1894 #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
1896 # due-date overlap should be determined by the location the item
1897 # is checked out from, not the owning or circ lib of the item
1898 my $org = $self->editor->requestor->ws_ou;
1900 $logger->info("circulator: circ searching for closed date overlap on lib $org".
1901 " with an item due date of ".$circ->due_date );
1903 my $dateinfo = $U->storagereq(
1904 'open-ils.storage.actor.org_unit.closed_date.overlap',
1905 $org, $circ->due_date );
1908 $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
1909 $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
1911 # XXX make the behavior more dynamic
1912 # for now, we just push the due date to after the close date
1913 if ($shift_earlier) {
1914 $circ->due_date($dateinfo->{start});
1916 $circ->due_date($dateinfo->{end});
1924 sub create_due_date {
1925 my( $self, $duration ) = @_;
1927 # if there is a raw time component (e.g. from postgres),
1928 # turn it into an interval that interval_to_seconds can parse
1929 $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
1931 # for now, use the server timezone. TODO: use workstation org timezone
1932 my $due_date = DateTime->now(time_zone => 'local');
1934 # add the circ duration
1935 $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
1937 # return ISO8601 time with timezone
1938 return $due_date->strftime('%FT%T%z');
1943 sub make_precat_copy {
1945 my $copy = $self->copy;
1948 $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
1950 $copy->editor($self->editor->requestor->id);
1951 $copy->edit_date('now');
1952 $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
1953 $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
1954 $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
1955 $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
1956 $self->update_copy();
1960 $logger->info("circulator: Creating a new precataloged ".
1961 "copy in checkout with barcode " . $self->copy_barcode);
1963 $copy = Fieldmapper::asset::copy->new;
1964 $copy->circ_lib($self->circ_lib);
1965 $copy->creator($self->editor->requestor->id);
1966 $copy->editor($self->editor->requestor->id);
1967 $copy->barcode($self->copy_barcode);
1968 $copy->call_number(OILS_PRECAT_CALL_NUMBER);
1969 $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
1970 $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
1972 $copy->dummy_title($self->dummy_title || "");
1973 $copy->dummy_author($self->dummy_author || "");
1974 $copy->dummy_isbn($self->dummy_isbn || "");
1975 $copy->circ_modifier($self->circ_modifier);
1978 # See if we need to override the circ_lib for the copy with a configured circ_lib
1979 # Setting is shortname of the org unit
1980 my $precat_circ_lib = $U->ou_ancestor_setting_value(
1981 $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
1983 if($precat_circ_lib) {
1984 my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
1987 $self->bail_on_events($self->editor->event);
1991 $copy->circ_lib($org->id);
1995 unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
1997 $self->push_events($self->editor->event);
2001 # this is a little bit of a hack, but we need to
2002 # get the copy into the script runner
2003 $self->script_runner->insert("environment.copy", $copy, 1) if $self->script_runner;
2007 sub checkout_noncat {
2013 my $lib = $self->noncat_circ_lib || $self->editor->requestor->ws_ou;
2014 my $count = $self->noncat_count || 1;
2015 my $cotime = cleanse_ISO8601($self->checkout_time) || "";
2017 $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2021 ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2022 $self->editor->requestor->id,
2030 $self->push_events($evt);
2041 $self->log_me("do_checkin()");
2043 return $self->bail_on_events(
2044 OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
2047 if( $self->checkin_check_holds_shelf() ) {
2048 $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2049 $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2050 $self->checkin_flesh_events;
2054 unless( $self->is_renewal ) {
2055 return $self->bail_on_events($self->editor->event)
2056 unless $self->editor->allowed('COPY_CHECKIN');
2059 $self->push_events($self->check_copy_alert());
2060 $self->push_events($self->check_checkin_copy_status());
2062 # the renew code will have already found our circulation object
2063 unless( $self->is_renewal and $self->circ ) {
2064 my $circs = $self->editor->search_action_circulation(
2065 { target_copy => $self->copy->id, checkin_time => undef });
2066 $self->circ($$circs[0]);
2068 # for now, just warn if there are multiple open circs on a copy
2069 $logger->warn("circulator: we have ".scalar(@$circs).
2070 " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2073 # run the fine generator against this circ, if this circ is there
2074 $self->generate_fines if ($self->circ);
2076 # if the circ is marked as 'claims returned', add the event to the list
2077 $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2078 if ($self->circ and $self->circ->stop_fines
2079 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2081 $self->check_circ_deposit();
2083 # handle the overridable events
2084 $self->override_events unless $self->is_renewal;
2085 return if $self->bail_out;
2089 $self->editor->search_action_transit_copy(
2090 { target_copy => $self->copy->id, dest_recv_time => undef }
2096 $self->checkin_handle_circ;
2097 return if $self->bail_out;
2098 $self->checkin_changed(1);
2100 } elsif( $self->transit ) {
2101 my $hold_transit = $self->process_received_transit;
2102 $self->checkin_changed(1);
2104 if( $self->bail_out ) {
2105 $self->checkin_flesh_events;
2109 if( my $e = $self->check_checkin_copy_status() ) {
2110 # If the original copy status is special, alert the caller
2111 my $ev = $self->events;
2112 $self->events([$e]);
2113 $self->override_events;
2114 return if $self->bail_out;
2118 if( $hold_transit or
2119 $U->copy_status($self->copy->status)->id
2120 == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2123 if( $hold_transit ) {
2124 $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2126 ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2131 if( $hold and $hold->cancel_time ) { # this transited hold was cancelled mid-transit
2133 $logger->info("circulator: we received a transit on a cancelled hold " . $hold->id);
2134 $self->reshelve_copy(1);
2135 $self->cancelled_hold_transit(1);
2136 $self->notify_hold(0); # don't notify for cancelled holds
2137 return if $self->bail_out;
2141 # hold transited to correct location
2142 $self->checkin_flesh_events;
2147 } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2149 $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2150 " that is in-transit, but there is no transit.. repairing");
2151 $self->reshelve_copy(1);
2152 return if $self->bail_out;
2155 if( $self->is_renewal ) {
2156 $self->push_events(OpenILS::Event->new('SUCCESS'));
2160 # ------------------------------------------------------------------------------
2161 # Circulations and transits are now closed where necessary. Now go on to see if
2162 # this copy can fulfill a hold or needs to be routed to a different location
2163 # ------------------------------------------------------------------------------
2165 if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2167 my $needed_for_hold = (!$self->remote_hold and $self->attempt_checkin_hold_capture());
2168 return if $self->bail_out;
2170 unless($needed_for_hold) {
2171 my $circ_lib = (ref $self->copy->circ_lib) ?
2172 $self->copy->circ_lib->id : $self->copy->circ_lib;
2174 if( $self->remote_hold ) {
2175 $circ_lib = $self->remote_hold->pickup_lib;
2176 $logger->warn("circulator: Copy ".$self->copy->barcode.
2177 " is on a remote hold's shelf, sending to $circ_lib");
2180 $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->editor->requestor->ws_ou);
2182 if( $circ_lib == $self->editor->requestor->ws_ou ) {
2183 # copy is where it needs to be, either for hold or reshelving
2185 $self->checkin_handle_precat();
2186 return if $self->bail_out;
2189 # copy needs to transit "home", or stick here if it's a floating copy
2191 if ($U->is_true( $self->copy->floating ) && !$self->remote_hold) { # copy is floating, stick here
2192 $self->checkin_changed(1);
2193 $self->copy->circ_lib( $self->editor->requestor->ws_ou );
2196 my $bc = $self->copy->barcode;
2197 $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2198 $self->checkin_build_copy_transit($circ_lib);
2199 return if $self->bail_out;
2200 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2204 } else { # no-op checkin
2205 if ($U->is_true( $self->copy->floating )) { # XXX floating items still stick where they are even with no-op checkin?
2206 $self->checkin_changed(1);
2207 $self->copy->circ_lib( $self->editor->requestor->ws_ou );
2212 if($self->claims_never_checked_out and
2213 $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2215 # the item was not supposed to be checked out to the user and should now be marked as missing
2216 $self->copy->status(OILS_COPY_STATUS_MISSING);
2220 $self->reshelve_copy;
2223 return if $self->bail_out;
2225 unless($self->checkin_changed) {
2227 $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2228 my $stat = $U->copy_status($self->copy->status)->id;
2230 $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2231 if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2232 $self->bail_out(1); # no need to commit anything
2236 $self->push_events(OpenILS::Event->new('SUCCESS'))
2237 unless @{$self->events};
2240 OpenILS::Utils::Penalty->calculate_penalties(
2241 $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2243 $self->checkin_flesh_events;
2247 # if a deposit was payed for this item, push the event
2248 sub check_circ_deposit {
2250 return unless $self->circ;
2251 my $deposit = $self->editor->search_money_billing(
2253 xact => $self->circ->id,
2255 }, {idlist => 1})->[0];
2257 $self->push_events(OpenILS::Event->new(
2258 'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2263 my $force = $self->force || shift;
2264 my $copy = $self->copy;
2266 my $stat = $U->copy_status($copy->status)->id;
2269 $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2270 $stat != OILS_COPY_STATUS_CATALOGING and
2271 $stat != OILS_COPY_STATUS_IN_TRANSIT and
2272 $stat != OILS_COPY_STATUS_RESHELVING )) {
2274 $copy->status( OILS_COPY_STATUS_RESHELVING );
2276 $self->checkin_changed(1);
2281 # Returns true if the item is at the current location
2282 # because it was transited there for a hold and the
2283 # hold has not been fulfilled
2284 sub checkin_check_holds_shelf {
2286 return 0 unless $self->copy;
2289 $U->copy_status($self->copy->status)->id ==
2290 OILS_COPY_STATUS_ON_HOLDS_SHELF;
2292 # find the hold that put us on the holds shelf
2293 my $holds = $self->editor->search_action_hold_request(
2295 current_copy => $self->copy->id,
2296 capture_time => { '!=' => undef },
2297 fulfillment_time => undef,
2298 cancel_time => undef,
2303 $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
2304 $self->reshelve_copy(1);
2308 my $hold = $$holds[0];
2310 $logger->info("circulator: we found a captured, un-fulfilled hold [".
2311 $hold->id. "] for copy ".$self->copy->barcode);
2313 if( $hold->pickup_lib == $self->editor->requestor->ws_ou ) {
2314 $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
2318 $logger->info("circulator: hold is not for here..");
2319 $self->remote_hold($hold);
2324 sub checkin_handle_precat {
2326 my $copy = $self->copy;
2328 if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
2329 $copy->status(OILS_COPY_STATUS_CATALOGING);
2330 $self->update_copy();
2331 $self->checkin_changed(1);
2332 $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
2337 sub checkin_build_copy_transit {
2340 my $copy = $self->copy;
2341 my $transit = Fieldmapper::action::transit_copy->new;
2343 #$dest ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
2344 $logger->info("circulator: transiting copy to $dest");
2346 $transit->source($self->editor->requestor->ws_ou);
2347 $transit->dest($dest);
2348 $transit->target_copy($copy->id);
2349 $transit->source_send_time('now');
2350 $transit->copy_status( $U->copy_status($copy->status)->id );
2352 $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
2354 return $self->bail_on_events($self->editor->event)
2355 unless $self->editor->create_action_transit_copy($transit);
2357 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2359 $self->checkin_changed(1);
2363 # returns true if the item was used (or may potentially be used
2364 # in subsequent calls) to capture a hold.
2365 sub attempt_checkin_hold_capture {
2367 my $copy = $self->copy;
2369 # we've been explicitly told not to capture any holds
2370 return 0 if $self->capture eq 'nocapture';
2372 # See if this copy can fulfill any holds
2373 my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
2374 $self->editor, $copy, $self->editor->requestor );
2377 $logger->debug("circulator: no potential permitted".
2378 "holds found for copy ".$copy->barcode);
2382 if($self->capture ne 'capture') {
2383 # see if this item is in a hold-capture-delay location
2384 my $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2385 if($U->is_true($location->hold_verify)) {
2386 $self->bail_on_events(
2387 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
2392 $self->retarget($retarget);
2394 $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
2396 $hold->current_copy($copy->id);
2397 $hold->capture_time('now');
2398 $self->put_hold_on_shelf($hold)
2399 if $hold->pickup_lib == $self->editor->requestor->ws_ou;
2401 # prevent DB errors caused by fetching
2402 # holds from storage, and updating through cstore
2403 $hold->clear_fulfillment_time;
2404 $hold->clear_fulfillment_staff;
2405 $hold->clear_fulfillment_lib;
2406 $hold->clear_expire_time;
2407 $hold->clear_cancel_time;
2408 $hold->clear_prev_check_time unless $hold->prev_check_time;
2410 $self->bail_on_events($self->editor->event)
2411 unless $self->editor->update_action_hold_request($hold);
2413 $self->checkin_changed(1);
2415 return 0 if $self->bail_out;
2417 if( $hold->pickup_lib == $self->editor->requestor->ws_ou ) {
2419 # This hold was captured in the correct location
2420 $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2421 $self->push_events(OpenILS::Event->new('SUCCESS'));
2423 #$self->do_hold_notify($hold->id);
2424 $self->notify_hold($hold->id);
2428 # Hold needs to be picked up elsewhere. Build a hold
2429 # transit and route the item.
2430 $self->checkin_build_hold_transit();
2431 $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2432 return 0 if $self->bail_out;
2433 $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
2436 # make sure we save the copy status
2441 sub do_hold_notify {
2442 my( $self, $holdid ) = @_;
2444 my $e = new_editor(xact => 1);
2445 my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
2447 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
2448 $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
2450 $logger->info("circulator: running delayed hold notify process");
2452 # my $notifier = OpenILS::Application::Circ::HoldNotify->new(
2453 # hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
2455 my $notifier = OpenILS::Application::Circ::HoldNotify->new(
2456 hold_id => $holdid, requestor => $self->editor->requestor);
2458 $logger->debug("circulator: built hold notifier");
2460 if(!$notifier->event) {
2462 $logger->info("circulator: attempt at sending hold notification for hold $holdid");
2464 my $stat = $notifier->send_email_notify;
2465 if( $stat == '1' ) {
2466 $logger->info("circulator: hold notify succeeded for hold $holdid");
2470 $logger->warn("circulator: * hold notify failed for hold $holdid");
2473 $logger->info("circulator: Not sending hold notification since the patron has no email address");
2477 sub retarget_holds {
2479 $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
2480 my $ses = OpenSRF::AppSession->create('open-ils.storage');
2481 $ses->request('open-ils.storage.action.hold_request.copy_targeter', undef, $self->retarget);
2482 # no reason to wait for the return value
2486 sub checkin_build_hold_transit {
2489 my $copy = $self->copy;
2490 my $hold = $self->hold;
2491 my $trans = Fieldmapper::action::hold_transit_copy->new;
2493 $logger->debug("circulator: building hold transit for ".$copy->barcode);
2495 $trans->hold($hold->id);
2496 $trans->source($self->editor->requestor->ws_ou);
2497 $trans->dest($hold->pickup_lib);
2498 $trans->source_send_time("now");
2499 $trans->target_copy($copy->id);
2501 # when the copy gets to its destination, it will recover
2502 # this status - put it onto the holds shelf
2503 $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2505 return $self->bail_on_events($self->editor->event)
2506 unless $self->editor->create_action_hold_transit_copy($trans);
2511 sub process_received_transit {
2513 my $copy = $self->copy;
2514 my $copyid = $self->copy->id;
2516 my $status_name = $U->copy_status($copy->status)->name;
2517 $logger->debug("circulator: attempting transit receive on ".
2518 "copy $copyid. Copy status is $status_name");
2520 my $transit = $self->transit;
2522 if( $transit->dest != $self->editor->requestor->ws_ou ) {
2523 # - this item is in-transit to a different location
2525 my $tid = $transit->id;
2526 my $loc = $self->editor->requestor->ws_ou;
2527 my $dest = $transit->dest;
2529 $logger->info("circulator: Fowarding transit on copy which is destined ".
2530 "for a different location. transit=$tid, copy=$copyid, current ".
2531 "location=$loc, destination location=$dest");
2533 my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
2535 # grab the associated hold object if available
2536 my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
2537 $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
2539 return $self->bail_on_events($evt);
2542 # The transit is received, set the receive time
2543 $transit->dest_recv_time('now');
2544 $self->bail_on_events($self->editor->event)
2545 unless $self->editor->update_action_transit_copy($transit);
2547 my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
2549 $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
2550 $copy->status( $transit->copy_status );
2551 $self->update_copy();
2552 return if $self->bail_out;
2556 my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2558 # hold has arrived at destination, set shelf time
2559 $self->put_hold_on_shelf($hold);
2560 $self->bail_on_events($self->editor->event)
2561 unless $self->editor->update_action_hold_request($hold);
2562 return if $self->bail_out;
2564 $self->notify_hold($hold_transit->hold);
2569 OpenILS::Event->new(
2572 payload => { transit => $transit, holdtransit => $hold_transit } ));
2574 return $hold_transit;
2578 # ------------------------------------------------------------------
2579 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
2580 # ------------------------------------------------------------------
2581 sub put_hold_on_shelf {
2582 my($self, $hold) = @_;
2584 $hold->shelf_time('now');
2586 my $shelf_expire = $U->ou_ancestor_setting_value(
2587 $self->circ_lib, 'circ.holds.default_shelf_expire_interval', $self->editor);
2590 my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
2591 my $expire_time = DateTime->now->add(seconds => $seconds);
2592 $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
2600 sub generate_fines {
2602 my $reservation = shift;
2606 my $id = $reservation ? $self->reservation->id : $self->circ->id;
2608 my $st = OpenSRF::AppSession->connect('open-ils.storage');
2611 'open-ils.storage.action.circulation.overdue.generate_fines',
2618 # refresh the circ in case the fine generator set the stop_fines field
2619 $self->reservation($self->editor->retrieve_booking_reservation($id)) if $reservation;
2620 $self->circ($self->editor->retrieve_action_circulation($id)) if !$reservation;
2625 sub checkin_handle_circ {
2627 my $circ = $self->circ;
2628 my $copy = $self->copy;
2632 $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
2634 # backdate the circ if necessary
2635 if($self->backdate) {
2636 $self->checkin_handle_backdate;
2637 return if $self->bail_out;
2640 if($self->void_overdues) {
2641 my $evt = OpenILS::Application::Circ::CircCommon->void_overdues(
2642 $self->editor, $circ, undef, 'System: Amnesty Checkin'); # TODO i18n for system-generated notes
2643 return $self->bail_on_events($evt) if $evt;
2646 if(!$circ->stop_fines) {
2647 $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
2648 $circ->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
2649 $circ->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
2650 $circ->stop_fines_time('now');
2651 $circ->stop_fines_time($self->backdate) if $self->backdate;
2654 # see if there are any fines owed on this circ. if not, close it
2655 ($obt) = $U->fetch_mbts($circ->id, $self->editor);
2656 $circ->xact_finish('now') if( $obt and $obt->balance_owed == 0 );
2658 $logger->debug("circulator: ".$obt->balance_owed." is owed on this circulation");
2660 # Set the checkin vars since we have the item
2661 $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
2663 # capture the true scan time for back-dated checkins
2664 $circ->checkin_scan_time('now');
2666 $circ->checkin_staff($self->editor->requestor->id);
2667 $circ->checkin_lib($self->editor->requestor->ws_ou);
2668 $circ->checkin_workstation($self->editor->requestor->wsid);
2670 my $circ_lib = (ref $self->copy->circ_lib) ?
2671 $self->copy->circ_lib->id : $self->copy->circ_lib;
2672 my $stat = $U->copy_status($self->copy->status)->id;
2674 # immediately available keeps items lost or missing items from going home before being handled
2675 my $lost_immediately_available = $U->ou_ancestor_setting_value(
2676 $circ_lib, OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE, $self->editor) || 0;
2679 if ( (!$lost_immediately_available) && ($circ_lib != $self->editor->requestor->ws_ou) ) {
2681 if( ($stat == OILS_COPY_STATUS_LOST or $stat == OILS_COPY_STATUS_MISSING) ) {
2682 $logger->info("circulator: not updating copy status on checkin because copy is lost/missing");
2684 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
2688 } elsif ($stat == OILS_COPY_STATUS_LOST) {
2690 $self->checkin_handle_lost($circ_lib);
2694 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
2698 return $self->bail_on_events($self->editor->event)
2699 unless $self->editor->update_action_circulation($circ);
2701 # make sure the circ isn't closed if we just voided some fines
2702 $evt = OpenILS::Application::Circ::CircCommon->reopen_xact($self->editor, $circ->id);
2703 return $self->bail_on_events($evt) if $evt;
2709 # ------------------------------------------------------------------
2710 # See if we need to void billings for lost checkin
2711 # ------------------------------------------------------------------
2712 sub checkin_handle_lost {
2714 my $circ_lib = shift;
2715 my $circ = $self->circ;
2717 my $max_return = $U->ou_ancestor_setting_value(
2718 $circ_lib, OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
2723 my @tm = reverse($circ->due_date =~ /([\d\.]+)/og);
2724 $tm[5] -= 1 if $tm[5] > 0;
2725 my $due = timelocal(int($tm[1]), int($tm[2]), int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
2727 my $last_chance = OpenSRF::Utils->interval_to_seconds($max_return) + int($due);
2728 $logger->info("MAX OD: ".$max_return." DUEDATE: ".$circ->due_date." TODAY: ".$today." DUE: ".$due." LAST: ".$last_chance);
2730 $max_return = 0 if $today < $last_chance;
2733 if (!$max_return){ # there's either no max time to accept returns defined or we're within that time
2735 my $void_lost = $U->ou_ancestor_setting_value(
2736 $circ_lib, OILS_SETTING_VOID_LOST_ON_CHECKIN, $self->editor) || 0;
2737 my $void_lost_fee = $U->ou_ancestor_setting_value(
2738 $circ_lib, OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN, $self->editor) || 0;
2739 my $restore_od = $U->ou_ancestor_setting_value(
2740 $circ_lib, OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN, $self->editor) || 0;
2742 $self->checkin_handle_lost_now_found(3) if $void_lost;
2743 $self->checkin_handle_lost_now_found(4) if $void_lost_fee;
2744 $self->checkin_handle_lost_now_found_restore_od() if $restore_od;
2747 $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
2752 sub checkin_handle_backdate {
2755 my $bd = $self->backdate;
2757 # ------------------------------------------------------------------
2758 # clean up the backdate for date comparison
2759 # we want any bills created on or after the backdate
2760 # ------------------------------------------------------------------
2761 $bd =~ s/^(\d{4}-\d{2}-\d{2}).*/$1/og;
2762 #$bd = "${bd}T23:59:59";
2764 my $bills = $self->editor->search_money_billing(
2766 billing_ts => { '>=' => $bd },
2767 xact => $self->circ->id,
2772 $logger->debug("backdate found ".scalar(@$bills)." bills to void");
2774 for my $bill (@$bills) {
2775 unless( $U->is_true($bill->voided) ) {
2776 $logger->info("backdate voiding bill ".$bill->id);
2778 $bill->void_time('now');
2779 $bill->voider($self->editor->requestor->id);
2780 my $n = $bill->note || "";
2781 $bill->note("$n\nSystem: VOIDED FOR BACKDATE");
2783 $self->bail_on_events($self->editor->event)
2784 unless $self->editor->update_money_billing($bill);
2792 sub find_patron_from_copy {
2794 my $circs = $self->editor->search_action_circulation(
2795 { target_copy => $self->copy->id, checkin_time => undef });
2796 my $circ = $circs->[0];
2797 return unless $circ;
2798 my $u = $self->editor->retrieve_actor_user($circ->usr)
2799 or return $self->bail_on_events($self->editor->event);
2803 sub check_checkin_copy_status {
2805 my $copy = $self->copy;
2811 my $status = $U->copy_status($copy->status)->id;
2814 if( $status == OILS_COPY_STATUS_AVAILABLE ||
2815 $status == OILS_COPY_STATUS_CHECKED_OUT ||
2816 $status == OILS_COPY_STATUS_IN_PROCESS ||
2817 $status == OILS_COPY_STATUS_ON_HOLDS_SHELF ||
2818 $status == OILS_COPY_STATUS_IN_TRANSIT ||
2819 $status == OILS_COPY_STATUS_CATALOGING ||
2820 $status == OILS_COPY_STATUS_ON_RESV_SHELF ||
2821 $status == OILS_COPY_STATUS_RESHELVING );
2823 return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
2824 if( $status == OILS_COPY_STATUS_LOST );
2826 return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
2827 if( $status == OILS_COPY_STATUS_MISSING );
2829 return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
2834 # --------------------------------------------------------------------------
2835 # On checkin, we need to return as many relevant objects as we can
2836 # --------------------------------------------------------------------------
2837 sub checkin_flesh_events {
2840 if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events}
2841 and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
2842 $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
2845 my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
2848 if($self->hold and !$self->hold->cancel_time) {
2849 $hold = $self->hold;
2850 $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
2854 # if we checked in a circulation, flesh the billing summary data
2855 $self->circ->billable_transaction(
2856 $self->editor->retrieve_money_billable_transaction([
2858 {flesh => 1, flesh_fields => {mbt => ['summary']}}
2864 # flesh some patron fields before returning
2866 $self->editor->retrieve_actor_user([
2871 au => ['card', 'billing_address', 'mailing_address']
2878 for my $evt (@{$self->events}) {
2881 $payload->{copy} = $U->unflesh_copy($self->copy);
2882 $payload->{record} = $record,
2883 $payload->{circ} = $self->circ;
2884 $payload->{transit} = $self->transit;
2885 $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
2886 $payload->{hold} = $hold;
2887 $payload->{patron} = $self->patron;
2888 $evt->{payload} = $payload;
2893 my( $self, $msg ) = @_;
2894 my $bc = ($self->copy) ? $self->copy->barcode :
2897 my $usr = ($self->patron) ? $self->patron->id : "";
2898 $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
2899 ", recipient=$usr, copy=$bc");
2905 $self->log_me("do_renew()");
2907 # Make sure there is an open circ to renew that is not
2908 # marked as LOST, CLAIMSRETURNED, or LONGOVERDUE
2909 my $usrid = $self->patron->id if $self->patron;
2912 # If we have a patron, match them to the circ
2913 $circ = $self->editor->search_action_circulation(
2914 {target_copy => $self->copy->id, usr => $usrid, stop_fines => undef})->[0];
2916 $circ = $self->editor->search_action_circulation(
2917 {target_copy => $self->copy->id, stop_fines => undef})->[0];
2922 $circ = $self->editor->search_action_circulation(
2923 {target_copy => $self->copy->id, usr => $usrid, stop_fines => OILS_STOP_FINES_MAX_FINES, checkin_time => undef})->[0];
2925 $circ = $self->editor->search_action_circulation(
2926 {target_copy => $self->copy->id, stop_fines => OILS_STOP_FINES_MAX_FINES, checkin_time => undef})->[0];
2930 return $self->bail_on_events($self->editor->event) unless $circ;
2932 # A user is not allowed to renew another user's items without permission
2933 unless( $circ->usr eq $self->editor->requestor->id ) {
2934 return $self->bail_on_events($self->editor->events)
2935 unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
2938 $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
2939 if $circ->renewal_remaining < 1;
2941 # -----------------------------------------------------------------
2943 $self->parent_circ($circ->id);
2944 $self->renewal_remaining( $circ->renewal_remaining - 1 );
2947 $self->run_renew_permit;
2950 $self->do_checkin();
2951 return if $self->bail_out;
2953 unless( $self->permit_override ) {
2955 return if $self->bail_out;
2956 $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
2957 $self->remove_event('ITEM_NOT_CATALOGED');
2960 $self->override_events;
2961 return if $self->bail_out;
2964 $self->do_checkout();
2969 my( $self, $evt ) = @_;
2970 $evt = (ref $evt) ? $evt->{textcode} : $evt;
2971 $logger->debug("circulator: removing event from list: $evt");
2972 my @events = @{$self->events};
2973 $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
2978 my( $self, $evt ) = @_;
2979 $evt = (ref $evt) ? $evt->{textcode} : $evt;
2980 return grep { $_->{textcode} eq $evt } @{$self->events};
2985 sub run_renew_permit {
2990 if(!$self->legacy_script_support) {
2991 my $results = $self->run_indb_circ_test;
2992 unless($self->circ_test_success) {
2993 push(@$events, $LEGACY_CIRC_EVENT_MAP->{$_->{fail_part}} || $_->{fail_part}) for @$results;
2998 my $runner = $self->script_runner;
3000 $runner->load($self->circ_permit_renew);
3001 my $result = $runner->run or
3002 throw OpenSRF::EX::ERROR ("Circ Permit Renew Script Died: $@");
3003 $events = $result->{events};
3004 $self->mk_script_runner;
3007 $logger->activity("circulator: circ_permit_renew for user ".
3008 $self->patron->id." returned events: @$events") if @$events;
3010 $self->push_events(OpenILS::Event->new($_)) for @$events;
3012 $logger->debug("circulator: re-creating script runner to be safe");
3016 sub append_reading_list {
3020 $self->is_checkout and
3025 my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
3027 # verify history is globally enabled and uses the bucket mechanism
3028 my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
3029 apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
3031 unless($htype eq 'bucket') {
3036 # verify the patron wants to retain the hisory
3037 my $setting = $e->search_actor_user_setting(
3038 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
3040 unless($setting and $setting->value) {
3045 my $bkt = $e->search_container_copy_bucket(
3046 {owner => $self->patron->id, btype => 'circ_history'})->[0];
3051 # find the next item position
3052 my $last_item = $e->search_container_copy_bucket_item(
3053 {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
3054 $pos = $last_item->pos + 1 if $last_item;
3057 # create the history bucket if necessary
3058 $bkt = Fieldmapper::container::copy_bucket->new;
3059 $bkt->owner($self->patron->id);
3061 $bkt->btype('circ_history');
3063 $e->create_container_copy_bucket($bkt) or return $e->die_event;
3066 my $item = Fieldmapper::container::copy_bucket_item->new;
3068 $item->bucket($bkt->id);
3069 $item->target_copy($self->copy->id);
3072 $e->create_container_copy_bucket_item($item) or return $e->die_event;
3079 sub make_trigger_events {
3081 return unless $self->circ;
3082 my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3083 $ses->request('open-ils.trigger.event.autocreate', 'checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
3084 $ses->request('open-ils.trigger.event.autocreate', 'checkin', $self->circ, $self->circ_lib) if $self->is_checkin;
3085 $ses->request('open-ils.trigger.event.autocreate', 'renewal', $self->circ, $self->circ_lib) if $self->is_renewal;
3092 sub checkin_handle_lost_now_found {
3093 my ($self, $bill_type) = @_;
3095 # ------------------------------------------------------------------
3096 # remove charge from patron's account if lost item is returned
3097 # ------------------------------------------------------------------
3099 my $bills = $self->editor->search_money_billing(
3101 xact => $self->circ->id,
3106 $logger->debug("voiding lost item charge of ".scalar(@$bills));
3107 for my $bill (@$bills) {
3108 if( !$U->is_true($bill->voided) ) {
3109 $logger->info("lost item returned - voiding bill ".$bill->id);
3111 $bill->void_time('now');
3112 $bill->voider($self->editor->requestor->id);
3113 my $note = ($bill->note) ? $bill->note . "\n" : '';
3114 $bill->note("${note}System: VOIDED FOR LOST ITEM RETURNED");
3116 $self->bail_on_events($self->editor->event)
3117 unless $self->editor->update_money_billing($bill);
3122 sub checkin_handle_lost_now_found_restore_od {
3125 # ------------------------------------------------------------------
3126 # restore those overdue charges voided when item was set to lost
3127 # ------------------------------------------------------------------
3129 my $ods = $self->editor->search_money_billing(
3131 xact => $self->circ->id,
3136 $logger->debug("returning overdue charges pre-lost ".scalar(@$ods));
3137 for my $bill (@$ods) {
3138 if( $U->is_true($bill->voided) ) {
3139 $logger->info("lost item returned - restoring overdue ".$bill->id);
3141 $bill->clear_void_time;
3142 $bill->voider($self->editor->requestor->id);
3143 my $note = ($bill->note) ? $bill->note . "\n" : '';
3144 $bill->note("${note}System: LOST RETURNED - OVERDUES REINSTATED");
3146 $self->bail_on_events($self->editor->event)
3147 unless $self->editor->update_money_billing($bill);