ce05bc0c2f006088e98258232795206eceaec51e
[working/Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Circ / Circulate.pm
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;
11 use DateTime;
12 my $U = "OpenILS::Application::AppUtils";
13
14 my %scripts;
15 my $booking_status;
16 my $opac_renewal_use_circ_lib;
17 my $desk_renewal_use_circ_lib;
18
19 sub determine_booking_status {
20     unless (defined $booking_status) {
21         my $router_name = OpenSRF::Utils::Config
22             ->current
23             ->bootstrap
24             ->router_name || 'router';
25
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)
29         };
30         $ses->disconnect;
31         $logger->info("booking status: " . ($booking_status ? "on" : "off"));
32     }
33
34     return $booking_status;
35 }
36
37
38 my $MK_ENV_FLESH = { 
39     flesh => 2, 
40     flesh_fields => {acp => ['call_number','parts','floating'], acn => ['record']}
41 };
42
43 sub initialize {}
44
45 __PACKAGE__->register_method(
46     method  => "run_method",
47     api_name    => "open-ils.circ.checkout.permit",
48     notes       => q/
49         Determines if the given checkout can occur
50         @param authtoken The login session key
51         @param params A trailing hash of named params including 
52             barcode : The copy barcode, 
53             patron : The patron the checkout is occurring for, 
54             renew : true or false - whether or not this is a renewal
55         @return The event that occurred during the permit check.  
56     /);
57
58
59 __PACKAGE__->register_method (
60     method      => 'run_method',
61     api_name        => 'open-ils.circ.checkout.permit.override',
62     signature   => q/@see open-ils.circ.checkout.permit/,
63 );
64
65
66 __PACKAGE__->register_method(
67     method  => "run_method",
68     api_name    => "open-ils.circ.checkout",
69     notes => q/
70         Checks out an item
71         @param authtoken The login session key
72         @param params A named hash of params including:
73             copy            The copy object
74             barcode     If no copy is provided, the copy is retrieved via barcode
75             copyid      If no copy or barcode is provide, the copy id will be use
76             patron      The patron's id
77             noncat      True if this is a circulation for a non-cataloted item
78             noncat_type The non-cataloged type id
79             noncat_circ_lib The location for the noncat circ.  
80             precat      The item has yet to be cataloged
81             dummy_title The temporary title of the pre-cataloded item
82             dummy_author The temporary authr of the pre-cataloded item
83                 Default is the home org of the staff member
84         @return The SUCCESS event on success, any other event depending on the error
85     /);
86
87 __PACKAGE__->register_method(
88     method  => "run_method",
89     api_name    => "open-ils.circ.checkin",
90     argc        => 2,
91     signature   => q/
92         Generic super-method for handling all copies
93         @param authtoken The login session key
94         @param params Hash of named parameters including:
95             barcode - The copy barcode
96             force   - If true, copies in bad statuses will be checked in and give good statuses
97             noop    - don't capture holds or put items into transit
98             void_overdues - void all overdues for the circulation (aka amnesty)
99             ...
100     /
101 );
102
103 __PACKAGE__->register_method(
104     method    => "run_method",
105     api_name  => "open-ils.circ.checkin.override",
106     signature => q/@see open-ils.circ.checkin/
107 );
108
109 __PACKAGE__->register_method(
110     method    => "run_method",
111     api_name  => "open-ils.circ.renew.override",
112     signature => q/@see open-ils.circ.renew/,
113 );
114
115
116 __PACKAGE__->register_method(
117     method  => "run_method",
118     api_name    => "open-ils.circ.renew",
119     notes       => <<"    NOTES");
120     PARAMS( authtoken, circ => circ_id );
121     open-ils.circ.renew(login_session, circ_object);
122     Renews the provided circulation.  login_session is the requestor of the
123     renewal and if the logged in user is not the same as circ->usr, then
124     the logged in user must have RENEW_CIRC permissions.
125     NOTES
126
127 __PACKAGE__->register_method(
128     method   => "run_method",
129     api_name => "open-ils.circ.checkout.full"
130 );
131 __PACKAGE__->register_method(
132     method   => "run_method",
133     api_name => "open-ils.circ.checkout.full.override"
134 );
135 __PACKAGE__->register_method(
136     method   => "run_method",
137     api_name => "open-ils.circ.reservation.pickup"
138 );
139 __PACKAGE__->register_method(
140     method   => "run_method",
141     api_name => "open-ils.circ.reservation.return"
142 );
143 __PACKAGE__->register_method(
144     method   => "run_method",
145     api_name => "open-ils.circ.reservation.return.override"
146 );
147 __PACKAGE__->register_method(
148     method   => "run_method",
149     api_name => "open-ils.circ.checkout.inspect",
150     desc     => q/Returns the circ matrix test result and, on success, the rule set and matrix test object/
151 );
152
153
154 sub run_method {
155     my( $self, $conn, $auth, $args ) = @_;
156     translate_legacy_args($args);
157     $args->{override_args} = { all => 1 } unless defined $args->{override_args};
158     my $api = $self->api_name;
159
160     my $circulator = 
161         OpenILS::Application::Circ::Circulator->new($auth, %$args);
162
163     return circ_events($circulator) if $circulator->bail_out;
164
165     $circulator->use_booking(determine_booking_status());
166
167     # --------------------------------------------------------------------------
168     # First, check for a booking transit, as the barcode may not be a copy
169     # barcode, but a resource barcode, and nothing else in here will work
170     # --------------------------------------------------------------------------
171
172     if ($circulator->use_booking && (my $bc = $circulator->copy_barcode) && $api !~ /checkout|inspect/) { # do we have a barcode?
173         my $resources = $circulator->editor->search_booking_resource( { barcode => $bc } ); # any resources by this barcode?
174         if (@$resources) { # yes!
175
176             my $res_id_list = [ map { $_->id } @$resources ];
177             my $transit = $circulator->editor->search_action_reservation_transit_copy(
178                 [
179                     { target_copy => $res_id_list, dest => $circulator->circ_lib, dest_recv_time => undef, cancel_time => undef },
180                     { order_by => { artc => 'source_send_time' }, limit => 1 }
181                 ]
182             )->[0]; # Any transit for this barcode?
183
184             if ($transit) { # yes! unwrap it.
185
186                 my $reservation = $circulator->editor->retrieve_booking_reservation( $transit->reservation );
187                 my $res_type    = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
188
189                 my $success_event = new OpenILS::Event(
190                     "SUCCESS", "payload" => {"reservation" => $reservation}
191                 );
192                 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
193                     if (my $copy = $circulator->editor->search_asset_copy([
194                         { barcode => $bc, deleted => 'f' }, $MK_ENV_FLESH
195                     ])->[0]) { # got a copy
196                         $copy->status( $transit->copy_status );
197                         $copy->editor($circulator->editor->requestor->id);
198                         $copy->edit_date('now');
199                         $circulator->editor->update_asset_copy($copy);
200                         $success_event->{"payload"}->{"record"} =
201                             $U->record_to_mvr($copy->call_number->record);
202                         $success_event->{"payload"}->{"volume"} = $copy->call_number;
203                         $copy->call_number($copy->call_number->id);
204                         $success_event->{"payload"}->{"copy"} = $copy;
205                     }
206                 }
207
208                 $transit->dest_recv_time('now');
209                 $circulator->editor->update_action_reservation_transit_copy( $transit );
210
211                 $circulator->editor->commit;
212                 # Formerly this branch just stopped here. Argh!
213                 $conn->respond_complete($success_event);
214                 return;
215             }
216         }
217     }
218
219     if ($circulator->use_booking) {
220         $circulator->is_res_checkin($circulator->is_checkin(1))
221             if $api =~ /reservation.return/ or (
222                 $api =~ /checkin/ and $circulator->seems_like_reservation()
223             );
224
225         $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
226     }
227
228     $circulator->is_renewal(1) if $api =~ /renew/;
229     $circulator->is_checkin(1) if $api =~ /checkin/;
230
231     $circulator->mk_env();
232     $circulator->noop(1) if $circulator->claims_never_checked_out;
233
234     return circ_events($circulator) if $circulator->bail_out;
235     
236     $circulator->override(1) if $api =~ /override/o;
237
238     if( $api =~ /checkout\.permit/ ) {
239         $circulator->do_permit();
240
241     } elsif( $api =~ /checkout.full/ ) {
242
243         # requesting a precat checkout implies that any required
244         # overrides have been performed.  Go ahead and re-override.
245         $circulator->skip_permit_key(1);
246         $circulator->override(1) if $circulator->request_precat;
247         $circulator->do_permit();
248         $circulator->is_checkout(1);
249         unless( $circulator->bail_out ) {
250             $circulator->events([]);
251             $circulator->do_checkout();
252         }
253
254     } elsif( $circulator->is_res_checkout ) {
255         $circulator->do_reservation_pickup();
256
257     } elsif( $api =~ /inspect/ ) {
258         my $data = $circulator->do_inspect();
259         $circulator->editor->rollback;
260         return $data;
261
262     } elsif( $api =~ /checkout/ ) {
263         $circulator->is_checkout(1);
264         $circulator->do_checkout();
265
266     } elsif( $circulator->is_res_checkin ) {
267         $circulator->do_reservation_return();
268         $circulator->do_checkin() if ($circulator->copy());
269     } elsif( $api =~ /checkin/ ) {
270         $circulator->do_checkin();
271
272     } elsif( $api =~ /renew/ ) {
273         $circulator->is_renewal(1);
274         $circulator->do_renew();
275     }
276
277     if( $circulator->bail_out ) {
278
279         my @ee;
280         # make sure no success event accidentally slip in
281         $circulator->events(
282             [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
283
284         # Log the events
285         my @e = @{$circulator->events};
286         push( @ee, $_->{textcode} ) for @e;
287         $logger->info("circulator: bailing out with events: " . (join ", ", @ee));
288
289         $circulator->editor->rollback;
290
291     } else {
292
293         # checkin and reservation return can result in modifications to
294         # actor.usr.claims_never_checked_out_count without also modifying
295         # actor.last_xact_id.  Perform a no-op update on the patron to
296         # force an update to last_xact_id.
297         if ($circulator->claims_never_checked_out && $circulator->patron) {
298             $circulator->editor->update_actor_user(
299                 $circulator->editor->retrieve_actor_user($circulator->patron->id))
300                 or return $circulator->editor->die_event;
301         }
302
303         $circulator->editor->commit;
304     }
305     
306     $conn->respond_complete(circ_events($circulator));
307
308     return undef if $circulator->bail_out;
309
310     $circulator->do_hold_notify($circulator->notify_hold)
311         if $circulator->notify_hold;
312     $circulator->retarget_holds if $circulator->retarget;
313     $circulator->append_reading_list;
314     $circulator->make_trigger_events;
315     
316     return undef;
317 }
318
319 sub circ_events {
320     my $circ = shift;
321     my @e = @{$circ->events};
322     # if we have multiple events, SUCCESS should not be one of them;
323     @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
324     return (@e == 1) ? $e[0] : \@e;
325 }
326
327
328 sub translate_legacy_args {
329     my $args = shift;
330
331     if( $$args{barcode} ) {
332         $$args{copy_barcode} = $$args{barcode};
333         delete $$args{barcode};
334     }
335
336     if( $$args{copyid} ) {
337         $$args{copy_id} = $$args{copyid};
338         delete $$args{copyid};
339     }
340
341     if( $$args{patronid} ) {
342         $$args{patron_id} = $$args{patronid};
343         delete $$args{patronid};
344     }
345
346     if( $$args{patron} and !ref($$args{patron}) ) {
347         $$args{patron_id} = $$args{patron};
348         delete $$args{patron};
349     }
350
351
352     if( $$args{noncat} ) {
353         $$args{is_noncat} = $$args{noncat};
354         delete $$args{noncat};
355     }
356
357     if( $$args{precat} ) {
358         $$args{is_precat} = $$args{request_precat} = $$args{precat};
359         delete $$args{precat};
360     }
361 }
362
363
364
365 # --------------------------------------------------------------------------
366 # This package actually manages all of the circulation logic
367 # --------------------------------------------------------------------------
368 package OpenILS::Application::Circ::Circulator;
369 use strict; use warnings;
370 use vars q/$AUTOLOAD/;
371 use DateTime;
372 use OpenILS::Utils::Fieldmapper;
373 use OpenSRF::Utils::Cache;
374 use Digest::MD5 qw(md5_hex);
375 use DateTime::Format::ISO8601;
376 use OpenILS::Utils::PermitHold;
377 use OpenSRF::Utils qw/:datetime/;
378 use OpenSRF::Utils::SettingsClient;
379 use OpenILS::Application::Circ::Holds;
380 use OpenILS::Application::Circ::Transit;
381 use OpenSRF::Utils::Logger qw(:logger);
382 use OpenILS::Utils::CStoreEditor qw/:funcs/;
383 use OpenILS::Const qw/:const/;
384 use OpenILS::Utils::Penalty;
385 use OpenILS::Application::Circ::CircCommon;
386 use Time::Local;
387
388 my $CC = "OpenILS::Application::Circ::CircCommon";
389 my $holdcode    = "OpenILS::Application::Circ::Holds";
390 my $transcode   = "OpenILS::Application::Circ::Transit";
391 my %user_groups;
392
393 sub DESTROY { }
394
395
396 # --------------------------------------------------------------------------
397 # Add a pile of automagic getter/setter methods
398 # --------------------------------------------------------------------------
399 my @AUTOLOAD_FIELDS = qw/
400     notify_hold
401     remote_hold
402     backdate
403     reservation
404     copy
405     copy_id
406     copy_barcode
407     patron
408     patron_id
409     patron_barcode
410     volume
411     title
412     is_renewal
413     is_checkout
414     is_res_checkout
415     is_precat
416     is_noncat
417     request_precat
418     is_checkin
419     is_res_checkin
420     noncat_type
421     editor
422     events
423     cache_handle
424     override
425     circ_permit_patron
426     circ_permit_copy
427     circ_duration
428     circ_recurring_fines
429     circ_max_fines
430     circ_permit_renew
431     circ
432     transit
433     hold
434     permit_key
435     noncat_circ_lib
436     noncat_count
437     checkout_time
438     dummy_title
439     dummy_author
440     dummy_isbn
441     circ_modifier
442     circ_lib
443     barcode
444     duration_level
445     recurring_fines_level
446     duration_rule
447     recurring_fines_rule
448     max_fine_rule
449     renewal_remaining
450     hard_due_date
451     due_date
452     fulfilled_holds
453     transit
454     checkin_changed
455     force
456     permit_override
457     pending_checkouts
458     cancelled_hold_transit
459     opac_renewal
460     phone_renewal
461     desk_renewal
462     sip_renewal
463     retarget
464     matrix_test_result
465     circ_matrix_matchpoint
466     circ_test_success
467     is_deposit
468     is_rental
469     deposit_billing
470     rental_billing
471     capture
472     noop
473     void_overdues
474     parent_circ
475     return_patron
476     claims_never_checked_out
477     skip_permit_key
478     skip_deposit_fee
479     skip_rental_fee
480     use_booking
481     clear_expired
482     retarget_mode
483     hold_as_transit
484     fake_hold_dest
485     limit_groups
486     override_args
487     checkout_is_for_hold
488     manual_float
489     dont_change_lost_zero
490     lost_bill_options
491     needs_lost_bill_handling
492 /;
493
494
495 sub AUTOLOAD {
496     my $self = shift;
497     my $type = ref($self) or die "$self is not an object";
498     my $data = shift;
499     my $name = $AUTOLOAD;
500     $name =~ s/.*://o;   
501
502     unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
503         $logger->error("circulator: $type: invalid autoload field: $name");
504         die "$type: invalid autoload field: $name\n" 
505     }
506
507     {
508         no strict 'refs';
509         *{"${type}::${name}"} = sub {
510             my $s = shift;
511             my $v = shift;
512             $s->{$name} = $v if defined $v;
513             return $s->{$name};
514         }
515     }
516     return $self->$name($data);
517 }
518
519
520 sub new {
521     my( $class, $auth, %args ) = @_;
522     $class = ref($class) || $class;
523     my $self = bless( {}, $class );
524
525     $self->events([]);
526     $self->editor(new_editor(xact => 1, authtoken => $auth));
527
528     unless( $self->editor->checkauth ) {
529         $self->bail_on_events($self->editor->event);
530         return $self;
531     }
532
533     $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
534
535     $self->$_($args{$_}) for keys %args;
536
537     $self->circ_lib(
538         ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
539
540     # if this is a renewal, default to desk_renewal
541     $self->desk_renewal(1) unless 
542         $self->opac_renewal or $self->phone_renewal or $self->sip_renewal;
543
544     $self->capture('') unless $self->capture;
545
546     unless(%user_groups) {
547         my $gps = $self->editor->retrieve_all_permission_grp_tree;
548         %user_groups = map { $_->id => $_ } @$gps;
549     }
550
551     return $self;
552 }
553
554
555 # --------------------------------------------------------------------------
556 # True if we should discontinue processing
557 # --------------------------------------------------------------------------
558 sub bail_out {
559     my( $self, $bool ) = @_;
560     if( defined $bool ) {
561         $logger->info("circulator: BAILING OUT") if $bool;
562         $self->{bail_out} = $bool;
563     }
564     return $self->{bail_out};
565 }
566
567
568 sub push_events {
569     my( $self, @evts ) = @_;
570     for my $e (@evts) {
571         next unless $e;
572         $e->{payload} = $self->copy if 
573               ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
574
575         $logger->info("circulator: pushing event ".$e->{textcode});
576         push( @{$self->events}, $e ) unless
577             grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
578     }
579 }
580
581 sub mk_permit_key {
582     my $self = shift;
583     return '' if $self->skip_permit_key;
584     my $key = md5_hex( time() . rand() . "$$" );
585     $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
586     return $self->permit_key($key);
587 }
588
589 sub check_permit_key {
590     my $self = shift;
591     return 1 if $self->skip_permit_key;
592     my $key = $self->permit_key;
593     return 0 unless $key;
594     my $k = "oils_permit_key_$key";
595     my $one = $self->cache_handle->get_cache($k);
596     $self->cache_handle->delete_cache($k);
597     return ($one) ? 1 : 0;
598 }
599
600 sub seems_like_reservation {
601     my $self = shift;
602
603     # Some words about the following method:
604     # 1) It requires the VIEW_USER permission, but that's not an
605     # issue, right, since all staff should have that?
606     # 2) It returns only one reservation at a time, even if an item can be
607     # and is currently overbooked.  Hmmm....
608     my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
609     my $result = $booking_ses->request(
610         "open-ils.booking.reservations.by_returnable_resource_barcode",
611         $self->editor->authtoken,
612         $self->copy_barcode
613     )->gather(1);
614     $booking_ses->disconnect;
615
616     return $self->bail_on_events($result) if defined $U->event_code($result);
617
618     if (@$result > 0) {
619         $self->reservation(shift @$result);
620         return 1;
621     } else {
622         return 0;
623     }
624
625 }
626
627 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
628 sub save_trimmed_copy {
629     my ($self, $copy) = @_;
630
631     $self->copy($copy);
632     $self->volume($copy->call_number);
633     $self->title($self->volume->record);
634     $self->copy->call_number($self->volume->id);
635     $self->volume->record($self->title->id);
636     $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
637     if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
638         $self->is_deposit(1) if $U->is_true($self->copy->deposit);
639         $self->is_rental(1) unless $U->is_true($self->copy->deposit);
640     }
641 }
642
643 sub mk_env {
644     my $self = shift;
645     my $e = $self->editor;
646
647     # --------------------------------------------------------------------------
648     # Grab the fleshed copy
649     # --------------------------------------------------------------------------
650     unless($self->is_noncat) {
651         my $copy;
652         if($self->copy_id) {
653             $copy = $e->retrieve_asset_copy(
654                 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
655     
656         } elsif( $self->copy_barcode ) {
657     
658             $copy = $e->search_asset_copy(
659                 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
660         } elsif( $self->reservation ) {
661             my $res = $e->json_query(
662                 {
663                     "select" => {"acp" => ["id"]},
664                     "from" => {
665                         "acp" => {
666                             "brsrc" => {
667                                 "fkey" => "barcode",
668                                 "field" => "barcode",
669                                 "join" => {
670                                     "bresv" => {
671                                         "fkey" => "id",
672                                         "field" => "current_resource"
673                                     }
674                                 }
675                             }
676                         }
677                     },
678                     "where" => {
679                         "+bresv" => {
680                             "id" => (ref $self->reservation) ?
681                                 $self->reservation->id : $self->reservation
682                         }
683                     }
684                 }
685             );
686             if (ref $res eq "ARRAY" and scalar @$res) {
687                 $logger->info("circulator: mapped reservation " .
688                     $self->reservation . " to copy " . $res->[0]->{"id"});
689                 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
690             }
691         }
692     
693         if($copy) {
694             $self->save_trimmed_copy($copy);
695         } else {
696             # We can't renew if there is no copy
697             return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
698                 if $self->is_renewal;
699             $self->is_precat(1);
700         }
701     }
702
703     # --------------------------------------------------------------------------
704     # Grab the patron
705     # --------------------------------------------------------------------------
706     my $patron;
707     my $flesh = {
708         flesh => 1,
709         flesh_fields => {au => [ qw/ card / ]}
710     };
711
712     if( $self->patron_id ) {
713         $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
714             or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
715
716     } elsif( $self->patron_barcode ) {
717
718         # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
719         my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0] 
720             or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
721
722         $patron = $e->retrieve_actor_user($card->usr)
723             or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
724
725         # Use the card we looked up, not the patron's primary, for card active checks
726         $patron->card($card);
727
728     } else {
729         if( my $copy = $self->copy ) {
730
731             $flesh->{flesh} = 2;
732             $flesh->{flesh_fields}->{circ} = ['usr'];
733
734             my $circ = $e->search_action_circulation([
735                 {target_copy => $copy->id, checkin_time => undef}, $flesh
736             ])->[0];
737
738             if($circ) {
739                 $patron = $circ->usr;
740                 $circ->usr($patron->id); # de-flesh for consistency
741                 $self->circ($circ); 
742             }
743         }
744     }
745
746     return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
747         unless $self->patron($patron) or $self->is_checkin;
748
749     unless($self->is_checkin) {
750
751         # Check for inactivity and patron reg. expiration
752
753         $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
754             unless $U->is_true($patron->active);
755     
756         $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
757             unless $U->is_true($patron->card->active);
758     
759         my $expire = DateTime::Format::ISO8601->new->parse_datetime(
760             cleanse_ISO8601($patron->expire_date));
761     
762         $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
763             if( CORE::time > $expire->epoch ) ;
764     }
765 }
766
767
768 # --------------------------------------------------------------------------
769 # Does the circ permit work
770 # --------------------------------------------------------------------------
771 sub do_permit {
772     my $self = shift;
773
774     $self->log_me("do_permit()");
775
776     unless( $self->editor->requestor->id == $self->patron->id ) {
777         return $self->bail_on_events($self->editor->event)
778             unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
779     }
780
781     $self->check_captured_holds();
782     $self->do_copy_checks();
783     return if $self->bail_out;
784     $self->run_patron_permit_scripts();
785     $self->run_copy_permit_scripts() 
786         unless $self->is_precat or $self->is_noncat;
787     $self->check_item_deposit_events();
788     $self->override_events();
789     return if $self->bail_out;
790
791     if($self->is_precat and not $self->request_precat) {
792         $self->push_events(
793             OpenILS::Event->new(
794                 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
795         return $self->bail_out(1) unless $self->is_renewal;
796     }
797
798     $self->push_events(
799         OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
800 }
801
802 sub check_item_deposit_events {
803     my $self = shift;
804     $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy)) 
805         if $self->is_deposit and not $self->is_deposit_exempt;
806     $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy)) 
807         if $self->is_rental and not $self->is_rental_exempt;
808 }
809
810 # returns true if the user is not required to pay deposits
811 sub is_deposit_exempt {
812     my $self = shift;
813     my $pid = (ref $self->patron->profile) ?
814         $self->patron->profile->id : $self->patron->profile;
815     my $groups = $U->ou_ancestor_setting_value(
816         $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
817     for my $grp (@$groups) {
818         return 1 if $self->is_group_descendant($grp, $pid);
819     }
820     return 0;
821 }
822
823 # returns true if the user is not required to pay rental fees
824 sub is_rental_exempt {
825     my $self = shift;
826     my $pid = (ref $self->patron->profile) ?
827         $self->patron->profile->id : $self->patron->profile;
828     my $groups = $U->ou_ancestor_setting_value(
829         $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
830     for my $grp (@$groups) {
831         return 1 if $self->is_group_descendant($grp, $pid);
832     }
833     return 0;
834 }
835
836 sub is_group_descendant {
837     my($self, $p_id, $c_id) = @_;
838     return 0 unless defined $p_id and defined $c_id;
839     return 1 if $c_id == $p_id;
840     while(my $grp = $user_groups{$c_id}) {
841         $c_id = $grp->parent;
842         return 0 unless defined $c_id;
843         return 1 if $c_id == $p_id;
844     }
845     return 0;
846 }
847
848 sub check_captured_holds {
849     my $self    = shift;
850     my $copy    = $self->copy;
851     my $patron  = $self->patron;
852
853     return undef unless $copy;
854
855     my $s = $U->copy_status($copy->status)->id;
856     return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
857     $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
858
859     # Item is on the holds shelf, make sure it's going to the right person
860     my $hold = $self->editor->search_action_hold_request(
861         [
862             { 
863                 current_copy        => $copy->id , 
864                 capture_time        => { '!=' => undef },
865                 cancel_time         => undef, 
866                 fulfillment_time    => undef 
867             },
868             { limit => 1 }
869         ]
870     )->[0];
871
872     if ($hold and $hold->usr == $patron->id) {
873         $self->checkout_is_for_hold(1);
874         return undef;
875     }
876
877     $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
878
879     $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF'));
880 }
881
882
883 sub do_copy_checks {
884     my $self = shift;
885     my $copy = $self->copy;
886     return unless $copy;
887
888     my $stat = $U->copy_status($copy->status)->id;
889
890     # We cannot check out a copy if it is in-transit
891     if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
892         return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
893     }
894
895     $self->handle_claims_returned();
896     return if $self->bail_out;
897
898     # no claims returned circ was found, check if there is any open circ
899     unless( $self->is_renewal ) {
900
901         my $circs = $self->editor->search_action_circulation(
902             { target_copy => $copy->id, checkin_time => undef }
903         );
904
905         if(my $old_circ = $circs->[0]) { # an open circ was found
906
907             my $payload = {copy => $copy};
908
909             if($old_circ->usr == $self->patron->id) {
910                 
911                 $payload->{old_circ} = $old_circ;
912
913                 # If there is an open circulation on the checkout item and an auto-renew 
914                 # interval is defined, inform the caller that they should go 
915                 # ahead and renew the item instead of warning about open circulations.
916     
917                 my $auto_renew_intvl = $U->ou_ancestor_setting_value(        
918                     $self->circ_lib,
919                     'circ.checkout_auto_renew_age', 
920                     $self->editor
921                 );
922
923                 if($auto_renew_intvl) {
924                     my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
925                     my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
926
927                     if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
928                         $payload->{auto_renew} = 1;
929                     }
930                 }
931             }
932
933             return $self->bail_on_events(
934                 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
935             );
936         }
937     }
938 }
939
940 my $LEGACY_CIRC_EVENT_MAP = {
941     'no_item' => 'ITEM_NOT_CATALOGED',
942     'actor.usr.barred' => 'PATRON_BARRED',
943     'asset.copy.circulate' =>  'COPY_CIRC_NOT_ALLOWED',
944     'asset.copy.status' => 'COPY_NOT_AVAILABLE',
945     'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
946     'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
947     'config.circ_matrix_test.max_items_out' =>  'PATRON_EXCEEDS_CHECKOUT_COUNT',
948     'config.circ_matrix_test.max_overdue' =>  'PATRON_EXCEEDS_OVERDUE_COUNT',
949     'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
950     'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
951     'config.circ_matrix_test.total_copy_hold_ratio' => 
952         'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
953     'config.circ_matrix_test.available_copy_hold_ratio' => 
954         'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
955 };
956
957
958 # ---------------------------------------------------------------------
959 # This pushes any patron-related events into the list but does not
960 # set bail_out for any events
961 # ---------------------------------------------------------------------
962 sub run_patron_permit_scripts {
963     my $self        = shift;
964     my $patronid    = $self->patron->id;
965
966     my @allevents; 
967
968
969     my $results = $self->run_indb_circ_test;
970     unless($self->circ_test_success) {
971         my @trimmed_results;
972
973         if ($self->is_noncat) {
974             # no_item result is OK during noncat checkout
975             @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
976
977         } else {
978
979             if ($self->checkout_is_for_hold) {
980                 # if this checkout will fulfill a hold, ignore CIRC blocks
981                 # and rely instead on the (later-checked) FULFILL block
982
983                 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
984                 my $fblock_pens = $self->editor->search_config_standing_penalty(
985                     {name => [@pen_names], block_list => {like => '%CIRC%'}});
986
987                 for my $res (@$results) {
988                     my $name = $res->{fail_part} || '';
989                     next if grep {$_->name eq $name} @$fblock_pens;
990                     push(@trimmed_results, $res);
991                 }
992
993             } else { 
994                 # not for hold or noncat
995                 @trimmed_results = @$results;
996             }
997         }
998
999         # update the final set of test results
1000         $self->matrix_test_result(\@trimmed_results); 
1001
1002         push @allevents, $self->matrix_test_result_events;
1003     }
1004
1005     for (@allevents) {
1006        $_->{payload} = $self->copy if 
1007              ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1008     }
1009
1010     $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1011
1012     $self->push_events(@allevents);
1013 }
1014
1015 sub matrix_test_result_codes {
1016     my $self = shift;
1017     map { $_->{"fail_part"} } @{$self->matrix_test_result};
1018 }
1019
1020 sub matrix_test_result_events {
1021     my $self = shift;
1022     map {
1023         my $event = new OpenILS::Event(
1024             $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1025         );
1026         $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1027         $event;
1028     } (@{$self->matrix_test_result});
1029 }
1030
1031 sub run_indb_circ_test {
1032     my $self = shift;
1033     return $self->matrix_test_result if $self->matrix_test_result;
1034
1035     my $dbfunc = ($self->is_renewal) ? 
1036         'action.item_user_renew_test' : 'action.item_user_circ_test';
1037
1038     if( $self->is_precat && $self->request_precat) {
1039         $self->make_precat_copy;
1040         return if $self->bail_out;
1041     }
1042
1043     my $results = $self->editor->json_query(
1044         {   from => [
1045                 $dbfunc,
1046                 $self->circ_lib,
1047                 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id, 
1048                 $self->patron->id,
1049             ]
1050         }
1051     );
1052
1053     $self->circ_test_success($U->is_true($results->[0]->{success}));
1054
1055     if(my $mp = $results->[0]->{matchpoint}) {
1056         $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1057         $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1058         $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1059         if(defined($results->[0]->{renewals})) {
1060             $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1061         }
1062         $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1063         if(defined($results->[0]->{grace_period})) {
1064             $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1065         }
1066         $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1067         if(defined($results->[0]->{hard_due_date})) {
1068             $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1069         }
1070         # Grab the *last* response for limit_groups, where it is more likely to be filled
1071         $self->limit_groups($results->[-1]->{limit_groups});
1072     }
1073
1074     return $self->matrix_test_result($results);
1075 }
1076
1077 # ---------------------------------------------------------------------
1078 # given a use and copy, this will calculate the circulation policy
1079 # parameters.  Only works with in-db circ.
1080 # ---------------------------------------------------------------------
1081 sub do_inspect {
1082     my $self = shift;
1083
1084     return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1085
1086     $self->run_indb_circ_test;
1087
1088     my $results = {
1089         circ_test_success => $self->circ_test_success,
1090         failure_events => [],
1091         failure_codes => [],
1092         matchpoint => $self->circ_matrix_matchpoint
1093     };
1094
1095     unless($self->circ_test_success) {
1096         $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1097         $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1098     }
1099
1100     if($self->circ_matrix_matchpoint) {
1101         my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1102         my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1103         my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1104         my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1105     
1106         my $policy = $self->get_circ_policy(
1107             $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1108     
1109         $$results{$_} = $$policy{$_} for keys %$policy;
1110     }
1111
1112     return $results;
1113 }
1114
1115 # ---------------------------------------------------------------------
1116 # Loads the circ policy info for duration, recurring fine, and max
1117 # fine based on the current copy
1118 # ---------------------------------------------------------------------
1119 sub get_circ_policy {
1120     my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1121
1122     my $policy = {
1123         duration_rule => $duration_rule->name,
1124         recurring_fine_rule => $recurring_fine_rule->name,
1125         max_fine_rule => $max_fine_rule->name,
1126         max_fine => $self->get_max_fine_amount($max_fine_rule),
1127         fine_interval => $recurring_fine_rule->recurrence_interval,
1128         renewal_remaining => $duration_rule->max_renewals,
1129         grace_period => $recurring_fine_rule->grace_period
1130     };
1131
1132     if($hard_due_date) {
1133         $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1134         $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1135     }
1136     else {
1137         $policy->{duration_date_ceiling} = undef;
1138         $policy->{duration_date_ceiling_force} = undef;
1139     }
1140
1141     $policy->{duration} = $duration_rule->shrt
1142         if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1143     $policy->{duration} = $duration_rule->normal
1144         if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1145     $policy->{duration} = $duration_rule->extended
1146         if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1147
1148     $policy->{recurring_fine} = $recurring_fine_rule->low
1149         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1150     $policy->{recurring_fine} = $recurring_fine_rule->normal
1151         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1152     $policy->{recurring_fine} = $recurring_fine_rule->high
1153         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1154
1155     return $policy;
1156 }
1157
1158 sub get_max_fine_amount {
1159     my $self = shift;
1160     my $max_fine_rule = shift;
1161     my $max_amount = $max_fine_rule->amount;
1162
1163     # if is_percent is true then the max->amount is
1164     # use as a percentage of the copy price
1165     if ($U->is_true($max_fine_rule->is_percent)) {
1166         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1167         $max_amount = $price * $max_fine_rule->amount / 100;
1168     } elsif (
1169         $U->ou_ancestor_setting_value(
1170             $self->circ_lib,
1171             'circ.max_fine.cap_at_price',
1172             $self->editor
1173         )
1174     ) {
1175         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1176         $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1177     }
1178
1179     return $max_amount;
1180 }
1181
1182
1183
1184 sub run_copy_permit_scripts {
1185     my $self = shift;
1186     my $copy = $self->copy || return;
1187
1188     my @allevents;
1189
1190     my $results = $self->run_indb_circ_test;
1191     push @allevents, $self->matrix_test_result_events
1192         unless $self->circ_test_success;
1193
1194     # See if this copy has an alert message
1195     my $ae = $self->check_copy_alert();
1196     push( @allevents, $ae ) if $ae;
1197
1198     # uniquify the events
1199     my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1200     @allevents = values %hash;
1201
1202     $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1203
1204     $self->push_events(@allevents);
1205 }
1206
1207
1208 sub check_copy_alert {
1209     my $self = shift;
1210     return undef if $self->is_renewal;
1211     return OpenILS::Event->new(
1212         'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1213         if $self->copy and $self->copy->alert_message;
1214     return undef;
1215 }
1216
1217
1218
1219 # --------------------------------------------------------------------------
1220 # If the call is overriding and has permissions to override every collected
1221 # event, the are cleared.  Any event that the caller does not have
1222 # permission to override, will be left in the event list and bail_out will
1223 # be set
1224 # XXX We need code in here to cancel any holds/transits on copies 
1225 # that are being force-checked out
1226 # --------------------------------------------------------------------------
1227 sub override_events {
1228     my $self = shift;
1229     my @events = @{$self->events};
1230     return unless @events;
1231     my $oargs = $self->override_args;
1232
1233     if(!$self->override) {
1234         return $self->bail_out(1) 
1235             if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1236     }   
1237
1238     $self->events([]);
1239     
1240     for my $e (@events) {
1241         my $tc = $e->{textcode};
1242         next if $tc eq 'SUCCESS';
1243         if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1244             my $ov = "$tc.override";
1245             $logger->info("circulator: attempting to override event: $ov");
1246
1247             return $self->bail_on_events($self->editor->event)
1248                 unless( $self->editor->allowed($ov) );
1249         } else {
1250             return $self->bail_out(1);
1251         }
1252    }
1253 }
1254     
1255
1256 # --------------------------------------------------------------------------
1257 # If there is an open claimsreturn circ on the requested copy, close the 
1258 # circ if overriding, otherwise bail out
1259 # --------------------------------------------------------------------------
1260 sub handle_claims_returned {
1261     my $self = shift;
1262     my $copy = $self->copy;
1263
1264     my $CR = $self->editor->search_action_circulation(
1265         {   
1266             target_copy     => $copy->id,
1267             stop_fines      => OILS_STOP_FINES_CLAIMSRETURNED,
1268             checkin_time    => undef,
1269         }
1270     );
1271
1272     return unless ($CR = $CR->[0]); 
1273
1274     my $evt;
1275
1276     # - If the caller has set the override flag, we will check the item in
1277     if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1278
1279         $CR->checkin_time('now');   
1280         $CR->checkin_scan_time('now');   
1281         $CR->checkin_lib($self->circ_lib);
1282         $CR->checkin_workstation($self->editor->requestor->wsid);
1283         $CR->checkin_staff($self->editor->requestor->id);
1284
1285         $evt = $self->editor->event 
1286             unless $self->editor->update_action_circulation($CR);
1287
1288     } else {
1289         $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1290     }
1291
1292     $self->bail_on_events($evt) if $evt;
1293     return;
1294 }
1295
1296
1297 # --------------------------------------------------------------------------
1298 # This performs the checkout
1299 # --------------------------------------------------------------------------
1300 sub do_checkout {
1301     my $self = shift;
1302
1303     $self->log_me("do_checkout()");
1304
1305     # make sure perms are good if this isn't a renewal
1306     unless( $self->is_renewal ) {
1307         return $self->bail_on_events($self->editor->event)
1308             unless( $self->editor->allowed('COPY_CHECKOUT') );
1309     }
1310
1311     # verify the permit key
1312     unless( $self->check_permit_key ) {
1313         if( $self->permit_override ) {
1314             return $self->bail_on_events($self->editor->event)
1315                 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1316         } else {
1317             return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1318         }   
1319     }
1320
1321     # if this is a non-cataloged circ, build the circ and finish
1322     if( $self->is_noncat ) {
1323         $self->checkout_noncat;
1324         $self->push_events(
1325             OpenILS::Event->new('SUCCESS', 
1326             payload => { noncat_circ => $self->circ }));
1327         return;
1328     }
1329
1330     if( $self->is_precat ) {
1331         $self->make_precat_copy;
1332         return if $self->bail_out;
1333
1334     } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1335         return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1336     }
1337
1338     $self->do_copy_checks;
1339     return if $self->bail_out;
1340
1341     $self->run_checkout_scripts();
1342     return if $self->bail_out;
1343
1344     $self->build_checkout_circ_object();
1345     return if $self->bail_out;
1346
1347     my $modify_to_start = $self->booking_adjusted_due_date();
1348     return if $self->bail_out;
1349
1350     $self->apply_modified_due_date($modify_to_start);
1351     return if $self->bail_out;
1352
1353     return $self->bail_on_events($self->editor->event)
1354         unless $self->editor->create_action_circulation($self->circ);
1355
1356     # refresh the circ to force local time zone for now
1357     $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1358
1359     if($self->limit_groups) {
1360         $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1361     }
1362
1363     $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1364     $self->update_copy;
1365     return if $self->bail_out;
1366
1367     $self->apply_deposit_fee();
1368     return if $self->bail_out;
1369
1370     $self->handle_checkout_holds();
1371     return if $self->bail_out;
1372
1373     # ------------------------------------------------------------------------------
1374     # Update the patron penalty info in the DB.  Run it for permit-overrides 
1375     # since the penalties are not updated during the permit phase
1376     # ------------------------------------------------------------------------------
1377     OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1378
1379     my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1380     
1381     my $pcirc;
1382     if($self->is_renewal) {
1383         # flesh the billing summary for the checked-in circ
1384         $pcirc = $self->editor->retrieve_action_circulation([
1385             $self->parent_circ,
1386             {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1387         ]);
1388     }
1389
1390     $self->push_events(
1391         OpenILS::Event->new('SUCCESS',
1392             payload  => {
1393                 copy             => $U->unflesh_copy($self->copy),
1394                 volume           => $self->volume,
1395                 circ             => $self->circ,
1396                 record           => $record,
1397                 holds_fulfilled  => $self->fulfilled_holds,
1398                 deposit_billing  => $self->deposit_billing,
1399                 rental_billing   => $self->rental_billing,
1400                 parent_circ      => $pcirc,
1401                 patron           => ($self->return_patron) ? $self->patron : undef,
1402                 patron_money     => $self->editor->retrieve_money_user_summary($self->patron->id)
1403             }
1404         )
1405     );
1406 }
1407
1408 sub apply_deposit_fee {
1409     my $self = shift;
1410     my $copy = $self->copy;
1411     return unless 
1412         ($self->is_deposit and not $self->is_deposit_exempt) or 
1413         ($self->is_rental and not $self->is_rental_exempt);
1414
1415     return if $self->is_deposit and $self->skip_deposit_fee;
1416     return if $self->is_rental and $self->skip_rental_fee;
1417
1418     my $bill = Fieldmapper::money::billing->new;
1419     my $amount = $copy->deposit_amount;
1420     my $billing_type;
1421     my $btype;
1422
1423     if($self->is_deposit) {
1424         $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1425         $btype = 5;
1426         $self->deposit_billing($bill);
1427     } else {
1428         $billing_type = OILS_BILLING_TYPE_RENTAL;
1429         $btype = 6;
1430         $self->rental_billing($bill);
1431     }
1432
1433     $bill->xact($self->circ->id);
1434     $bill->amount($amount);
1435     $bill->note(OILS_BILLING_NOTE_SYSTEM);
1436     $bill->billing_type($billing_type);
1437     $bill->btype($btype);
1438     $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1439
1440     $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1441 }
1442
1443 sub update_copy {
1444     my $self = shift;
1445     my $copy = $self->copy;
1446
1447     my $stat = $copy->status if ref $copy->status;
1448     my $loc = $copy->location if ref $copy->location;
1449     my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1450
1451     $copy->status($stat->id) if $stat;
1452     $copy->location($loc->id) if $loc;
1453     $copy->circ_lib($circ_lib->id) if $circ_lib;
1454     $copy->editor($self->editor->requestor->id);
1455     $copy->edit_date('now');
1456     $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1457
1458     return $self->bail_on_events($self->editor->event)
1459         unless $self->editor->update_asset_copy($self->copy);
1460
1461     $copy->status($U->copy_status($copy->status));
1462     $copy->location($loc) if $loc;
1463     $copy->circ_lib($circ_lib) if $circ_lib;
1464 }
1465
1466 sub update_reservation {
1467     my $self = shift;
1468     my $reservation = $self->reservation;
1469
1470     my $usr = $reservation->usr;
1471     my $target_rt = $reservation->target_resource_type;
1472     my $target_r = $reservation->target_resource;
1473     my $current_r = $reservation->current_resource;
1474
1475     $reservation->usr($usr->id) if ref $usr;
1476     $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1477     $reservation->target_resource($target_r->id) if ref $target_r;
1478     $reservation->current_resource($current_r->id) if ref $current_r;
1479
1480     return $self->bail_on_events($self->editor->event)
1481         unless $self->editor->update_booking_reservation($self->reservation);
1482
1483     my $evt;
1484     ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1485     $self->reservation($reservation);
1486 }
1487
1488
1489 sub bail_on_events {
1490     my( $self, @evts ) = @_;
1491     $self->push_events(@evts);
1492     $self->bail_out(1);
1493 }
1494
1495 # ------------------------------------------------------------------------------
1496 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1497 # affects copies that will fulfill holds and CIRC affects all other copies.
1498 # If blocks exists, bail, push Events onto the event pile, and return true.
1499 # ------------------------------------------------------------------------------
1500 sub check_hold_fulfill_blocks {
1501     my $self = shift;
1502
1503     # With the addition of ignore_proximity in csp, we need to fetch
1504     # the proximity of both the circ_lib and the copy's circ_lib to
1505     # the patron's home_ou.
1506     my ($ou_prox, $copy_prox);
1507     my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1508     $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1509     $ou_prox = -1 unless (defined($ou_prox));
1510     my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1511     if ($copy_ou == $self->circ_lib) {
1512         # Save us the time of an extra query.
1513         $copy_prox = $ou_prox;
1514     } else {
1515         $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1516         $copy_prox = -1 unless (defined($copy_prox));
1517     }
1518
1519     # See if the user has any penalties applied that prevent hold fulfillment
1520     my $pens = $self->editor->json_query({
1521         select => {csp => ['name', 'label']},
1522         from => {ausp => {csp => {}}},
1523         where => {
1524             '+ausp' => {
1525                 usr => $self->patron->id,
1526                 org_unit => $U->get_org_full_path($self->circ_lib),
1527                 '-or' => [
1528                     {stop_date => undef},
1529                     {stop_date => {'>' => 'now'}}
1530                 ]
1531             },
1532             '+csp' => {
1533                 block_list => {'like' => '%FULFILL%'},
1534                 '-or' => [
1535                     {ignore_proximity => undef},
1536                     {ignore_proximity => {'<' => $ou_prox}},
1537                     {ignore_proximity => {'<' => $copy_prox}}
1538                 ]
1539             }
1540         }
1541     });
1542
1543     return 0 unless @$pens;
1544
1545     for my $pen (@$pens) {
1546         $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1547         my $event = OpenILS::Event->new($pen->{name});
1548         $event->{desc} = $pen->{label};
1549         $self->push_events($event);
1550     }
1551
1552     $self->override_events;
1553     return $self->bail_out;
1554 }
1555
1556
1557 # ------------------------------------------------------------------------------
1558 # When an item is checked out, see if we can fulfill a hold for this patron
1559 # ------------------------------------------------------------------------------
1560 sub handle_checkout_holds {
1561    my $self    = shift;
1562    my $copy    = $self->copy;
1563    my $patron  = $self->patron;
1564
1565    my $e = $self->editor;
1566    $self->fulfilled_holds([]);
1567
1568    # non-cats can't fulfill a hold
1569    return if $self->is_noncat;
1570
1571     my $hold = $e->search_action_hold_request({   
1572         current_copy        => $copy->id , 
1573         cancel_time         => undef, 
1574         fulfillment_time    => undef
1575     })->[0];
1576
1577     if($hold and $hold->usr != $patron->id) {
1578         # reset the hold since the copy is now checked out
1579     
1580         $logger->info("circulator: un-targeting hold ".$hold->id.
1581             " because copy ".$copy->id." is getting checked out");
1582
1583         $hold->clear_prev_check_time; 
1584         $hold->clear_current_copy;
1585         $hold->clear_capture_time;
1586         $hold->clear_shelf_time;
1587         $hold->clear_shelf_expire_time;
1588         $hold->clear_current_shelf_lib;
1589
1590         return $self->bail_on_event($e->event)
1591             unless $e->update_action_hold_request($hold);
1592
1593         $hold = undef;
1594     }
1595
1596     unless($hold) {
1597         $hold = $self->find_related_user_hold($copy, $patron) or return;
1598         $logger->info("circulator: found related hold to fulfill in checkout");
1599     }
1600
1601     return if $self->check_hold_fulfill_blocks;
1602
1603     $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1604
1605     # if the hold was never officially captured, capture it.
1606     $hold->current_copy($copy->id);
1607     $hold->capture_time('now') unless $hold->capture_time;
1608     $hold->fulfillment_time('now');
1609     $hold->fulfillment_staff($e->requestor->id);
1610     $hold->fulfillment_lib($self->circ_lib);
1611
1612     return $self->bail_on_events($e->event)
1613         unless $e->update_action_hold_request($hold);
1614
1615     return $self->fulfilled_holds([$hold->id]);
1616 }
1617
1618
1619 # ------------------------------------------------------------------------------
1620 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1621 # the patron directly targets the checked out item, see if there is another hold 
1622 # for the patron that could be fulfilled by the checked out item.  Fulfill the
1623 # oldest hold and only fulfill 1 of them.
1624
1625 # For "another hold":
1626 #
1627 # First, check for one that the copy matches via hold_copy_map, ensuring that
1628 # *any* hold type that this copy could fill may end up filled.
1629 #
1630 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1631 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1632 # that are non-requestable to count as capturing those hold types.
1633 # ------------------------------------------------------------------------------
1634 sub find_related_user_hold {
1635     my($self, $copy, $patron) = @_;
1636     my $e = $self->editor;
1637
1638     # holds on precat copies are always copy-level, so this call will
1639     # always return undef.  Exit early.
1640     return undef if $self->is_precat;
1641
1642     return undef unless $U->ou_ancestor_setting_value(        
1643         $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1644
1645     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1646     my $args = {
1647         select => {ahr => ['id']}, 
1648         from => {
1649             ahr => {
1650                 ahcm => {
1651                     field => 'hold',
1652                     fkey => 'id'
1653                 },
1654                 acp => {
1655                     field => 'id', 
1656                     fkey => 'current_copy',
1657                     type => 'left' # there may be no current_copy
1658                 }
1659             }
1660         }, 
1661         where => {
1662             '+ahr' => {
1663                 usr => $patron->id,
1664                 fulfillment_time => undef,
1665                 cancel_time => undef,
1666                '-or' => [
1667                     {expire_time => undef},
1668                     {expire_time => {'>' => 'now'}}
1669                 ]
1670             },
1671             '+ahcm' => {
1672                 target_copy => $self->copy->id
1673             },
1674             '+acp' => {
1675                 '-or' => [
1676                     {id => undef}, # left-join copy may be nonexistent
1677                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1678                 ]
1679             }
1680         },
1681         order_by => {ahr => {request_time => {direction => 'asc'}}},
1682         limit => 1
1683     };
1684
1685     my $hold_info = $e->json_query($args)->[0];
1686     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1687     return undef if $U->ou_ancestor_setting_value(        
1688         $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1689
1690     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1691     $args = {
1692         select => {ahr => ['id']}, 
1693         from => {
1694             ahr => {
1695                 acp => {
1696                     field => 'id', 
1697                     fkey => 'current_copy',
1698                     type => 'left' # there may be no current_copy
1699                 }
1700             }
1701         }, 
1702         where => {
1703             '+ahr' => {
1704                 usr => $patron->id,
1705                 fulfillment_time => undef,
1706                 cancel_time => undef,
1707                '-or' => [
1708                     {expire_time => undef},
1709                     {expire_time => {'>' => 'now'}}
1710                 ]
1711             },
1712             '-or' => [
1713                 {
1714                     '+ahr' => { 
1715                         hold_type => 'V',
1716                         target => $self->volume->id
1717                     }
1718                 },
1719                 { 
1720                     '+ahr' => { 
1721                         hold_type => 'T',
1722                         target => $self->title->id
1723                     }
1724                 },
1725             ],
1726             '+acp' => {
1727                 '-or' => [
1728                     {id => undef}, # left-join copy may be nonexistent
1729                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1730                 ]
1731             }
1732         },
1733         order_by => {ahr => {request_time => {direction => 'asc'}}},
1734         limit => 1
1735     };
1736
1737     $hold_info = $e->json_query($args)->[0];
1738     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1739     return undef;
1740 }
1741
1742
1743 sub run_checkout_scripts {
1744     my $self = shift;
1745     my $nobail = shift;
1746
1747     my $evt;
1748
1749     my $duration;
1750     my $recurring;
1751     my $max_fine;
1752     my $hard_due_date;
1753     my $duration_name;
1754     my $recurring_name;
1755     my $max_fine_name;
1756     my $hard_due_date_name;
1757
1758     $self->run_indb_circ_test();
1759     $duration = $self->circ_matrix_matchpoint->duration_rule;
1760     $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
1761     $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
1762     $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1763
1764     $duration_name = $duration->name if $duration;
1765     if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
1766
1767         unless($duration) {
1768             ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
1769             return $self->bail_on_events($evt) if ($evt && !$nobail);
1770         
1771             ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
1772             return $self->bail_on_events($evt) if ($evt && !$nobail);
1773         
1774             ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
1775             return $self->bail_on_events($evt) if ($evt && !$nobail);
1776
1777             if($hard_due_date_name) {
1778                 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
1779                 return $self->bail_on_events($evt) if ($evt && !$nobail);
1780             }
1781         }
1782
1783     } else {
1784
1785         # The item circulates with an unlimited duration
1786         $duration   = undef;
1787         $recurring  = undef;
1788         $max_fine   = undef;
1789         $hard_due_date = undef;
1790     }
1791
1792    $self->duration_rule($duration);
1793    $self->recurring_fines_rule($recurring);
1794    $self->max_fine_rule($max_fine);
1795    $self->hard_due_date($hard_due_date);
1796 }
1797
1798
1799 sub build_checkout_circ_object {
1800     my $self = shift;
1801
1802    my $circ       = Fieldmapper::action::circulation->new;
1803    my $duration   = $self->duration_rule;
1804    my $max        = $self->max_fine_rule;
1805    my $recurring  = $self->recurring_fines_rule;
1806    my $hard_due_date    = $self->hard_due_date;
1807    my $copy       = $self->copy;
1808    my $patron     = $self->patron;
1809    my $duration_date_ceiling;
1810    my $duration_date_ceiling_force;
1811
1812     if( $duration ) {
1813
1814         my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
1815         $duration_date_ceiling = $policy->{duration_date_ceiling};
1816         $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
1817
1818         my $dname = $duration->name;
1819         my $mname = $max->name;
1820         my $rname = $recurring->name;
1821         my $hdname = ''; 
1822         if($hard_due_date) {
1823             $hdname = $hard_due_date->name;
1824         }
1825
1826         $logger->debug("circulator: building circulation ".
1827             "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
1828     
1829         $circ->duration($policy->{duration});
1830         $circ->recurring_fine($policy->{recurring_fine});
1831         $circ->duration_rule($duration->name);
1832         $circ->recurring_fine_rule($recurring->name);
1833         $circ->max_fine_rule($max->name);
1834         $circ->max_fine($policy->{max_fine});
1835         $circ->fine_interval($recurring->recurrence_interval);
1836         $circ->renewal_remaining($duration->max_renewals);
1837         $circ->grace_period($policy->{grace_period});
1838
1839     } else {
1840
1841         $logger->info("circulator: copy found with an unlimited circ duration");
1842         $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
1843         $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1844         $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1845         $circ->renewal_remaining(0);
1846         $circ->grace_period(0);
1847     }
1848
1849    $circ->target_copy( $copy->id );
1850    $circ->usr( $patron->id );
1851    $circ->circ_lib( $self->circ_lib );
1852    $circ->workstation($self->editor->requestor->wsid) 
1853     if defined $self->editor->requestor->wsid;
1854
1855     # renewals maintain a link to the parent circulation
1856     $circ->parent_circ($self->parent_circ);
1857
1858    if( $self->is_renewal ) {
1859       $circ->opac_renewal('t') if $self->opac_renewal;
1860       $circ->phone_renewal('t') if $self->phone_renewal;
1861       $circ->desk_renewal('t') if $self->desk_renewal;
1862       $circ->renewal_remaining($self->renewal_remaining);
1863       $circ->circ_staff($self->editor->requestor->id);
1864    }
1865
1866
1867     # if the user provided an overiding checkout time,
1868     # (e.g. the checkout really happened several hours ago), then
1869     # we apply that here.  Does this need a perm??
1870     $circ->xact_start(cleanse_ISO8601($self->checkout_time))
1871         if $self->checkout_time;
1872
1873     # if a patron is renewing, 'requestor' will be the patron
1874     $circ->circ_staff($self->editor->requestor->id);
1875     $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
1876
1877     $self->circ($circ);
1878 }
1879
1880 sub do_reservation_pickup {
1881     my $self = shift;
1882
1883     $self->log_me("do_reservation_pickup()");
1884
1885     $self->reservation->pickup_time('now');
1886
1887     if (
1888         $self->reservation->current_resource &&
1889         $U->is_true($self->reservation->target_resource_type->catalog_item)
1890     ) {
1891         # We used to try to set $self->copy and $self->patron here,
1892         # but that should already be done.
1893
1894         $self->run_checkout_scripts(1);
1895
1896         my $duration   = $self->duration_rule;
1897         my $max        = $self->max_fine_rule;
1898         my $recurring  = $self->recurring_fines_rule;
1899
1900         if ($duration && $max && $recurring) {
1901             my $policy = $self->get_circ_policy($duration, $recurring, $max);
1902
1903             my $dname = $duration->name;
1904             my $mname = $max->name;
1905             my $rname = $recurring->name;
1906
1907             $logger->debug("circulator: updating reservation ".
1908                 "with duration=$dname, maxfine=$mname, recurring=$rname");
1909
1910             $self->reservation->fine_amount($policy->{recurring_fine});
1911             $self->reservation->max_fine($policy->{max_fine});
1912             $self->reservation->fine_interval($recurring->recurrence_interval);
1913         }
1914
1915         $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1916         $self->update_copy();
1917
1918     } else {
1919         $self->reservation->fine_amount(
1920             $self->reservation->target_resource_type->fine_amount
1921         );
1922         $self->reservation->max_fine(
1923             $self->reservation->target_resource_type->max_fine
1924         );
1925         $self->reservation->fine_interval(
1926             $self->reservation->target_resource_type->fine_interval
1927         );
1928     }
1929
1930     $self->update_reservation();
1931 }
1932
1933 sub do_reservation_return {
1934     my $self = shift;
1935     my $request = shift;
1936
1937     $self->log_me("do_reservation_return()");
1938
1939     if (not ref $self->reservation) {
1940         my ($reservation, $evt) =
1941             $U->fetch_booking_reservation($self->reservation);
1942         return $self->bail_on_events($evt) if $evt;
1943         $self->reservation($reservation);
1944     }
1945
1946     $self->handle_fines(1);
1947     $self->reservation->return_time('now');
1948     $self->update_reservation();
1949     $self->reshelve_copy if $self->copy;
1950
1951     if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
1952         $self->copy( $self->reservation->current_resource->catalog_item );
1953     }
1954 }
1955
1956 sub booking_adjusted_due_date {
1957     my $self = shift;
1958     my $circ = $self->circ;
1959     my $copy = $self->copy;
1960
1961     return undef unless $self->use_booking;
1962
1963     my $changed;
1964
1965     if( $self->due_date ) {
1966
1967         return $self->bail_on_events($self->editor->event)
1968             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
1969
1970        $circ->due_date(cleanse_ISO8601($self->due_date));
1971
1972     } else {
1973
1974         return unless $copy and $circ->due_date;
1975     }
1976
1977     my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
1978     if (@$booking_items) {
1979         my $booking_item = $booking_items->[0];
1980         my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
1981
1982         my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
1983         my $shorten_circ_setting = $resource_type->elbow_room ||
1984             $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
1985             '0 seconds';
1986
1987         my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
1988         my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
1989               resource     => $booking_item->id
1990             , search_start => 'now'
1991             , search_end   => $circ->due_date
1992             , fields       => { cancel_time => undef, return_time => undef }
1993         })->gather(1);
1994         $booking_ses->disconnect;
1995
1996         throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
1997         return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
1998         
1999         my $dt_parser = DateTime::Format::ISO8601->new;
2000         my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2001
2002         for my $bid (@$bookings) {
2003
2004             my $booking = $self->editor->retrieve_booking_reservation( $bid );
2005
2006             my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2007             my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2008
2009             return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2010                 if ($booking_start < DateTime->now);
2011
2012
2013             if ($U->is_true($stop_circ_setting)) {
2014                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ); 
2015             } else {
2016                 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2017                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now); 
2018             }
2019             
2020             # We set the circ duration here only to affect the logic that will
2021             # later (in a DB trigger) mangle the time part of the due date to
2022             # 11:59pm. Having any circ duration that is not a whole number of
2023             # days is enough to prevent the "correction."
2024             my $new_circ_duration = $due_date->epoch - time;
2025             $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2026             $circ->duration("$new_circ_duration seconds");
2027
2028             $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2029             $changed = 1;
2030         }
2031
2032         return $self->bail_on_events($self->editor->event)
2033             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2034     }
2035
2036     return $changed;
2037 }
2038
2039 sub apply_modified_due_date {
2040     my $self = shift;
2041     my $shift_earlier = shift;
2042     my $circ = $self->circ;
2043     my $copy = $self->copy;
2044
2045    if( $self->due_date ) {
2046
2047         return $self->bail_on_events($self->editor->event)
2048             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2049
2050       $circ->due_date(cleanse_ISO8601($self->due_date));
2051
2052    } else {
2053
2054       # if the due_date lands on a day when the location is closed
2055       return unless $copy and $circ->due_date;
2056
2057         #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2058
2059         # due-date overlap should be determined by the location the item
2060         # is checked out from, not the owning or circ lib of the item
2061         my $org = $self->circ_lib;
2062
2063       $logger->info("circulator: circ searching for closed date overlap on lib $org".
2064             " with an item due date of ".$circ->due_date );
2065
2066       my $dateinfo = $U->storagereq(
2067          'open-ils.storage.actor.org_unit.closed_date.overlap', 
2068             $org, $circ->due_date );
2069
2070       if($dateinfo) {
2071          $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2072             $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2073
2074             # XXX make the behavior more dynamic
2075             # for now, we just push the due date to after the close date
2076             if ($shift_earlier) {
2077                 $circ->due_date($dateinfo->{start});
2078             } else {
2079                 $circ->due_date($dateinfo->{end});
2080             }
2081       }
2082    }
2083 }
2084
2085
2086
2087 sub create_due_date {
2088     my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2089
2090     # if there is a raw time component (e.g. from postgres), 
2091     # turn it into an interval that interval_to_seconds can parse
2092     $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2093
2094     # for now, use the server timezone.  TODO: use workstation org timezone
2095     my $due_date = DateTime->now(time_zone => 'local');
2096     $due_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)) if $start_time;
2097
2098     # add the circ duration
2099     $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2100
2101     if($date_ceiling) {
2102         my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2103         if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2104             $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2105             $due_date = $cdate;
2106         }
2107     }
2108
2109     # return ISO8601 time with timezone
2110     return $due_date->strftime('%FT%T%z');
2111 }
2112
2113
2114
2115 sub make_precat_copy {
2116     my $self = shift;
2117     my $copy = $self->copy;
2118
2119    if($copy) {
2120         $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2121
2122         $copy->editor($self->editor->requestor->id);
2123         $copy->edit_date('now');
2124         $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2125         $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2126         $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2127         $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2128         $self->update_copy();
2129         return;
2130    }
2131
2132     $logger->info("circulator: Creating a new precataloged ".
2133         "copy in checkout with barcode " . $self->copy_barcode);
2134
2135     $copy = Fieldmapper::asset::copy->new;
2136     $copy->circ_lib($self->circ_lib);
2137     $copy->creator($self->editor->requestor->id);
2138     $copy->editor($self->editor->requestor->id);
2139     $copy->barcode($self->copy_barcode);
2140     $copy->call_number(OILS_PRECAT_CALL_NUMBER); 
2141     $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2142     $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2143
2144     $copy->dummy_title($self->dummy_title || "");
2145     $copy->dummy_author($self->dummy_author || "");
2146     $copy->dummy_isbn($self->dummy_isbn || "");
2147     $copy->circ_modifier($self->circ_modifier);
2148
2149
2150     # See if we need to override the circ_lib for the copy with a configured circ_lib
2151     # Setting is shortname of the org unit
2152     my $precat_circ_lib = $U->ou_ancestor_setting_value(
2153         $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2154
2155     if($precat_circ_lib) {
2156         my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2157
2158         if(!$org) {
2159             $self->bail_on_events($self->editor->event);
2160             return;
2161         }
2162
2163         $copy->circ_lib($org->id);
2164     }
2165
2166
2167     unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2168         $self->bail_out(1);
2169         $self->push_events($self->editor->event);
2170         return;
2171     }   
2172 }
2173
2174
2175 sub checkout_noncat {
2176     my $self = shift;
2177
2178     my $circ;
2179     my $evt;
2180
2181    my $lib      = $self->noncat_circ_lib || $self->circ_lib;
2182    my $count    = $self->noncat_count || 1;
2183    my $cotime   = cleanse_ISO8601($self->checkout_time) || "";
2184
2185    $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2186
2187    for(1..$count) {
2188
2189       ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2190          $self->editor->requestor->id, 
2191             $self->patron->id, 
2192             $lib, 
2193             $self->noncat_type, 
2194             $cotime,
2195             $self->editor );
2196
2197         if( $evt ) {
2198             $self->push_events($evt);
2199             $self->bail_out(1);
2200             return; 
2201         }
2202         $self->circ($circ);
2203    }
2204 }
2205
2206 # If a copy goes into transit and is then checked in before the transit checkin 
2207 # interval has expired, push an event onto the overridable events list.
2208 sub check_transit_checkin_interval {
2209     my $self = shift;
2210
2211     # only concerned with in-transit items
2212     return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2213
2214     # no interval, no problem
2215     my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2216     return unless $interval;
2217
2218     # capture the transit so we don't have to fetch it again later during checkin
2219     $self->transit(
2220         $self->editor->search_action_transit_copy(
2221             {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2222         )->[0]
2223     ); 
2224
2225     # transit from X to X for whatever reason has no min interval
2226     return if $self->transit->source == $self->transit->dest;
2227
2228     my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2229     my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2230     my $horizon = $t_start->add(seconds => $seconds);
2231
2232     # See if we are still within the transit checkin forbidden range
2233     $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK')) 
2234         if $horizon > DateTime->now;
2235 }
2236
2237 # Retarget local holds at checkin
2238 sub checkin_retarget {
2239     my $self = shift;
2240     return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2241     return unless $self->is_checkin; # Renewals need not be checked
2242     return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2243     return if $self->is_precat; # No holds for precats
2244     return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2245     return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2246     my $status = $U->copy_status($self->copy->status);
2247     return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2248     # Specifically target items that are likely new (by status ID)
2249     return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2250     my $location = $self->copy->location;
2251     if(!ref($location)) {
2252         $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2253         $self->copy->location($location);
2254     }
2255     return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2256
2257     # Fetch holds for the bib
2258     my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2259                     $self->editor->authtoken,
2260                     $self->title->id,
2261                     {
2262                         capture_time => undef, # No touching captured holds
2263                         frozen => 'f', # Don't bother with frozen holds
2264                         pickup_lib => $self->circ_lib # Only holds actually here
2265                     }); 
2266
2267     # Error? Skip the step.
2268     return if exists $result->{"ilsevent"};
2269
2270     # Assemble holds
2271     my $holds = [];
2272     foreach my $holdlist (keys %{$result}) {
2273         push @$holds, @{$result->{$holdlist}};
2274     }
2275
2276     return if scalar(@$holds) == 0; # No holds, no retargeting
2277
2278     # Check for parts on this copy
2279     my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2280     my %parts_hash = ();
2281     %parts_hash = map {$_->part, 1} @$parts if @$parts;
2282
2283     # Loop over holds in request-ish order
2284     # Stage 1: Get them into request-ish order
2285     # Also grab type and target for skipping low hanging ones
2286     $result = $self->editor->json_query({
2287         "select" => { "ahr" => ["id", "hold_type", "target"] },
2288         "from" => { "ahr" => { "au" => { "fkey" => "usr",  "join" => "pgt"} } },
2289         "where" => { "id" => $holds },
2290         "order_by" => [
2291             { "class" => "pgt", "field" => "hold_priority"},
2292             { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2293             { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2294             { "class" => "ahr", "field" => "request_time"}
2295         ]
2296     });
2297
2298     # Stage 2: Loop!
2299     if (ref $result eq "ARRAY" and scalar @$result) {
2300         foreach (@{$result}) {
2301             # Copy level, but not this copy?
2302             next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2303                 and $_->{target} != $self->copy->id);
2304             # Volume level, but not this volume?
2305             next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2306             if(@$parts) { # We have parts?
2307                 # Skip title holds
2308                 next if ($_->{hold_type} eq 'T');
2309                 # Skip part holds for parts not on this copy
2310                 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2311             } else {
2312                 # No parts, no part holds
2313                 next if ($_->{hold_type} eq 'P');
2314             }
2315             # So much for easy stuff, attempt a retarget!
2316             my $tresult = $U->simplereq(
2317                 'open-ils.hold-targeter',
2318                 'open-ils.hold-targeter.target', 
2319                 {hold => $_->{id}, find_copy => $self->copy->id}
2320             );
2321             if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2322                 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2323             }
2324         }
2325     }
2326 }
2327
2328 sub do_checkin {
2329     my $self = shift;
2330     $self->log_me("do_checkin()");
2331
2332     return $self->bail_on_events(
2333         OpenILS::Event->new('ASSET_COPY_NOT_FOUND')) 
2334         unless $self->copy;
2335
2336     $self->check_transit_checkin_interval;
2337     $self->checkin_retarget;
2338
2339     # the renew code and mk_env should have already found our circulation object
2340     unless( $self->circ ) {
2341
2342         my $circs = $self->editor->search_action_circulation(
2343             { target_copy => $self->copy->id, checkin_time => undef });
2344
2345         $self->circ($$circs[0]);
2346
2347         # for now, just warn if there are multiple open circs on a copy
2348         $logger->warn("circulator: we have ".scalar(@$circs).
2349             " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2350     }
2351
2352     my $stat = $U->copy_status($self->copy->status)->id;
2353
2354     # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2355     # differently if they are already paid for.  We need to check for this
2356     # early since overdue generation is potentially affected.
2357     my $dont_change_lost_zero = 0;
2358     if ($stat == OILS_COPY_STATUS_LOST
2359         || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2360         || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2361
2362         # LOST fine settings are controlled by the copy's circ lib, not the the
2363         # circulation's
2364         my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2365                 $self->copy->circ_lib->id : $self->copy->circ_lib;
2366         $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2367             $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2368             $self->editor) || 0;
2369
2370         if ($dont_change_lost_zero) {
2371             my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2372             $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2373         }
2374
2375         $self->dont_change_lost_zero($dont_change_lost_zero);
2376     }
2377
2378     if( $self->checkin_check_holds_shelf() ) {
2379         $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2380         $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2381         if($self->fake_hold_dest) {
2382             $self->hold->pickup_lib($self->circ_lib);
2383         }
2384         $self->checkin_flesh_events;
2385         return;
2386     }
2387
2388     unless( $self->is_renewal ) {
2389         return $self->bail_on_events($self->editor->event)
2390             unless $self->editor->allowed('COPY_CHECKIN');
2391     }
2392
2393     $self->push_events($self->check_copy_alert());
2394     $self->push_events($self->check_checkin_copy_status());
2395
2396     # if the circ is marked as 'claims returned', add the event to the list
2397     $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2398         if ($self->circ and $self->circ->stop_fines 
2399                 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2400
2401     $self->check_circ_deposit();
2402
2403     # handle the overridable events 
2404     $self->override_events unless $self->is_renewal;
2405     return if $self->bail_out;
2406     
2407     if( $self->copy and !$self->transit ) {
2408         $self->transit(
2409             $self->editor->search_action_transit_copy(
2410                 { target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef }
2411             )->[0]
2412         ); 
2413     }
2414
2415     if( $self->circ ) {
2416         $self->checkin_handle_circ_start;
2417         return if $self->bail_out;
2418
2419         if (!$dont_change_lost_zero) {
2420             # if this circ is LOST and we are configured to generate overdue
2421             # fines for lost items on checkin (to fill the gap between mark
2422             # lost time and when the fines would have naturally stopped), then
2423             # stop_fines is no longer valid and should be cleared.
2424             #
2425             # stop_fines will be set again during the handle_fines() stage.
2426             # XXX should this setting come from the copy circ lib (like other
2427             # LOST settings), instead of the circulation circ lib?
2428             if ($stat == OILS_COPY_STATUS_LOST) {
2429                 $self->circ->clear_stop_fines if
2430                     $U->ou_ancestor_setting_value(
2431                         $self->circ_lib,
2432                         OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2433                         $self->editor
2434                     );
2435             }
2436
2437             # Set stop_fines when claimed never checked out
2438             $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2439
2440             # handle fines for this circ, including overdue gen if needed
2441             $self->handle_fines;
2442         }
2443
2444         $self->checkin_handle_circ_finish;
2445         return if $self->bail_out;
2446         $self->checkin_changed(1);
2447
2448     } elsif( $self->transit ) {
2449         my $hold_transit = $self->process_received_transit;
2450         $self->checkin_changed(1);
2451
2452         if( $self->bail_out ) { 
2453             $self->checkin_flesh_events;
2454             return;
2455         }
2456         
2457         if( my $e = $self->check_checkin_copy_status() ) {
2458             # If the original copy status is special, alert the caller
2459             my $ev = $self->events;
2460             $self->events([$e]);
2461             $self->override_events;
2462             return if $self->bail_out;
2463             $self->events($ev);
2464         }
2465
2466         if( $hold_transit or 
2467                 $U->copy_status($self->copy->status)->id 
2468                     == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2469
2470             my $hold;
2471             if( $hold_transit ) {
2472                $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2473             } else {
2474                    ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2475             }
2476
2477             $self->hold($hold);
2478
2479             if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2480
2481                 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2482                 $self->reshelve_copy(1);
2483                 $self->cancelled_hold_transit(1);
2484                 $self->notify_hold(0); # don't notify for cancelled holds
2485                 $self->fake_hold_dest(0);
2486                 return if $self->bail_out;
2487
2488             } elsif ($hold and $hold->hold_type eq 'R') {
2489
2490                 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2491                 $self->notify_hold(0); # No need to notify
2492                 $self->fake_hold_dest(0);
2493                 $self->noop(1); # Don't try and capture for other holds/transits now
2494                 $self->update_copy();
2495                 $hold->fulfillment_time('now');
2496                 $self->bail_on_events($self->editor->event)
2497                     unless $self->editor->update_action_hold_request($hold);
2498
2499             } else {
2500
2501                 # hold transited to correct location
2502                 if($self->fake_hold_dest) {
2503                     $hold->pickup_lib($self->circ_lib);
2504                 }
2505                 $self->checkin_flesh_events;
2506                 return;
2507             }
2508         } 
2509
2510     } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2511
2512         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2513             " that is in-transit, but there is no transit.. repairing");
2514         $self->reshelve_copy(1);
2515         return if $self->bail_out;
2516     }
2517
2518     if( $self->is_renewal ) {
2519         $self->finish_fines_and_voiding;
2520         return if $self->bail_out;
2521         $self->push_events(OpenILS::Event->new('SUCCESS'));
2522         return;
2523     }
2524
2525    # ------------------------------------------------------------------------------
2526    # Circulations and transits are now closed where necessary.  Now go on to see if
2527    # this copy can fulfill a hold or needs to be routed to a different location
2528    # ------------------------------------------------------------------------------
2529
2530     my $needed_for_something = 0; # formerly "needed_for_hold"
2531
2532     if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2533
2534         if (!$self->remote_hold) {
2535             if ($self->use_booking) {
2536                 my $potential_hold = $self->hold_capture_is_possible;
2537                 my $potential_reservation = $self->reservation_capture_is_possible;
2538
2539                 if ($potential_hold and $potential_reservation) {
2540                     $logger->info("circulator: item could fulfill either hold or reservation");
2541                     $self->push_events(new OpenILS::Event(
2542                         "HOLD_RESERVATION_CONFLICT",
2543                         "hold" => $potential_hold,
2544                         "reservation" => $potential_reservation
2545                     ));
2546                     return if $self->bail_out;
2547                 } elsif ($potential_hold) {
2548                     $needed_for_something =
2549                         $self->attempt_checkin_hold_capture;
2550                 } elsif ($potential_reservation) {
2551                     $needed_for_something =
2552                         $self->attempt_checkin_reservation_capture;
2553                 }
2554             } else {
2555                 $needed_for_something = $self->attempt_checkin_hold_capture;
2556             }
2557         }
2558         return if $self->bail_out;
2559     
2560         unless($needed_for_something) {
2561             my $circ_lib = (ref $self->copy->circ_lib) ? 
2562                     $self->copy->circ_lib->id : $self->copy->circ_lib;
2563     
2564             if( $self->remote_hold ) {
2565                 $circ_lib = $self->remote_hold->pickup_lib;
2566                 $logger->warn("circulator: Copy ".$self->copy->barcode.
2567                     " is on a remote hold's shelf, sending to $circ_lib");
2568             }
2569     
2570             $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2571
2572             my $suppress_transit = 0;
2573
2574             if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2575                 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2576                 if($suppress_transit_source && $suppress_transit_source->{value}) {
2577                     my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2578                     if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2579                         $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2580                         $suppress_transit = 1;
2581                     }
2582                 }
2583             }
2584  
2585             if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2586                 # copy is where it needs to be, either for hold or reshelving
2587     
2588                 $self->checkin_handle_precat();
2589                 return if $self->bail_out;
2590     
2591             } else {
2592                 # copy needs to transit "home", or stick here if it's a floating copy
2593                 my $can_float = 0;
2594                 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2595                     my $res = $self->editor->json_query(
2596                         {   from => [
2597                                 'evergreen.can_float',
2598                                 $self->copy->floating->id,
2599                                 $self->copy->circ_lib,
2600                                 $self->circ_lib
2601                             ]
2602                         }
2603                     );
2604                     $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res; 
2605                 }
2606                 if ($can_float) { # Yep, floating, stick here
2607                     $self->checkin_changed(1);
2608                     $self->copy->circ_lib( $self->circ_lib );
2609                     $self->update_copy;
2610                 } else {
2611                     my $bc = $self->copy->barcode;
2612                     $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2613                     $self->checkin_build_copy_transit($circ_lib);
2614                     return if $self->bail_out;
2615                     $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2616                 }
2617             }
2618         }
2619     } else { # no-op checkin
2620         if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2621             my $res = $self->editor->json_query(
2622                 {
2623                     from => [
2624                         'evergreen.can_float',
2625                         $self->copy->floating->id,
2626                         $self->copy->circ_lib,
2627                         $self->circ_lib
2628                     ]
2629                 }
2630             );
2631             if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2632                 $self->checkin_changed(1);
2633                 $self->copy->circ_lib( $self->circ_lib );
2634                 $self->update_copy;
2635             }
2636         }
2637     }
2638
2639     if($self->claims_never_checked_out and 
2640             $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2641
2642         # the item was not supposed to be checked out to the user and should now be marked as missing
2643         $self->copy->status(OILS_COPY_STATUS_MISSING);
2644         $self->update_copy;
2645
2646     } else {
2647         $self->reshelve_copy unless $needed_for_something;
2648     }
2649
2650     return if $self->bail_out;
2651
2652     unless($self->checkin_changed) {
2653
2654         $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2655         my $stat = $U->copy_status($self->copy->status)->id;
2656
2657         $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2658          if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2659         $self->bail_out(1); # no need to commit anything
2660
2661     } else {
2662
2663         $self->push_events(OpenILS::Event->new('SUCCESS')) 
2664             unless @{$self->events};
2665     }
2666
2667     $self->finish_fines_and_voiding;
2668
2669     OpenILS::Utils::Penalty->calculate_penalties(
2670         $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2671
2672     $self->checkin_flesh_events;
2673     return;
2674 }
2675
2676 sub finish_fines_and_voiding {
2677     my $self = shift;
2678     return unless $self->circ;
2679
2680     return unless $self->backdate or $self->void_overdues;
2681
2682     # void overdues after fine generation to prevent concurrent DB access to overdue billings
2683     my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2684
2685     my $evt = $CC->void_or_zero_overdues(
2686         $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
2687
2688     return $self->bail_on_events($evt) if $evt;
2689
2690     # Make sure the circ is open or closed as necessary.
2691     $evt = $U->check_open_xact($self->editor, $self->circ->id);
2692     return $self->bail_on_events($evt) if $evt;
2693
2694     return undef;
2695 }
2696
2697
2698 # if a deposit was payed for this item, push the event
2699 sub check_circ_deposit {
2700     my $self = shift;
2701     return unless $self->circ;
2702     my $deposit = $self->editor->search_money_billing(
2703         {   btype => 5, 
2704             xact => $self->circ->id, 
2705             voided => 'f'
2706         }, {idlist => 1})->[0];
2707
2708     $self->push_events(OpenILS::Event->new(
2709         'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2710 }
2711
2712 sub reshelve_copy {
2713    my $self    = shift;
2714    my $force   = $self->force || shift;
2715    my $copy    = $self->copy;
2716
2717    my $stat = $U->copy_status($copy->status)->id;
2718
2719    if($force || (
2720       $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2721       $stat != OILS_COPY_STATUS_CATALOGING and
2722       $stat != OILS_COPY_STATUS_IN_TRANSIT and
2723       $stat != OILS_COPY_STATUS_RESHELVING  )) {
2724
2725         $copy->status( OILS_COPY_STATUS_RESHELVING );
2726             $self->update_copy;
2727             $self->checkin_changed(1);
2728     }
2729 }
2730
2731
2732 # Returns true if the item is at the current location
2733 # because it was transited there for a hold and the 
2734 # hold has not been fulfilled
2735 sub checkin_check_holds_shelf {
2736     my $self = shift;
2737     return 0 unless $self->copy;
2738
2739     return 0 unless 
2740         $U->copy_status($self->copy->status)->id ==
2741             OILS_COPY_STATUS_ON_HOLDS_SHELF;
2742
2743     # Attempt to clear shelf expired holds for this copy
2744     $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
2745         if($self->clear_expired);
2746
2747     # find the hold that put us on the holds shelf
2748     my $holds = $self->editor->search_action_hold_request(
2749         { 
2750             current_copy => $self->copy->id,
2751             capture_time => { '!=' => undef },
2752             fulfillment_time => undef,
2753             cancel_time => undef,
2754         }
2755     );
2756
2757     unless(@$holds) {
2758         $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
2759         $self->reshelve_copy(1);
2760         return 0;
2761     }
2762
2763     my $hold = $$holds[0];
2764
2765     $logger->info("circulator: we found a captured, un-fulfilled hold [".
2766         $hold->id. "] for copy ".$self->copy->barcode);
2767
2768     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2769         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2770         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2771             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2772             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2773                 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
2774                 $self->fake_hold_dest(1);
2775                 return 1;
2776             }
2777         }
2778     }
2779
2780     if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
2781         $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
2782         return 1;
2783     }
2784
2785     $logger->info("circulator: hold is not for here..");
2786     $self->remote_hold($hold);
2787     return 0;
2788 }
2789
2790
2791 sub checkin_handle_precat {
2792     my $self    = shift;
2793    my $copy    = $self->copy;
2794
2795    if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
2796         $copy->status(OILS_COPY_STATUS_CATALOGING);
2797         $self->update_copy();
2798         $self->checkin_changed(1);
2799         $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
2800    }
2801 }
2802
2803
2804 sub checkin_build_copy_transit {
2805     my $self            = shift;
2806     my $dest            = shift;
2807     my $copy       = $self->copy;
2808     my $transit    = Fieldmapper::action::transit_copy->new;
2809
2810     # if we are transiting an item to the shelf shelf, it's a hold transit
2811     if (my $hold = $self->remote_hold) {
2812         $transit = Fieldmapper::action::hold_transit_copy->new;
2813         $transit->hold($hold->id);
2814
2815         # the item is going into transit, remove any shelf-iness
2816         if ($hold->current_shelf_lib or $hold->shelf_time) {
2817             $hold->clear_current_shelf_lib;
2818             $hold->clear_shelf_time;
2819             return $self->bail_on_events($self->editor->event)
2820                 unless $self->editor->update_action_hold_request($hold);
2821         }
2822     }
2823
2824     #$dest  ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
2825     $logger->info("circulator: transiting copy to $dest");
2826
2827     $transit->source($self->circ_lib);
2828     $transit->dest($dest);
2829     $transit->target_copy($copy->id);
2830     $transit->source_send_time('now');
2831     $transit->copy_status( $U->copy_status($copy->status)->id );
2832
2833     $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
2834
2835     if ($self->remote_hold) {
2836         return $self->bail_on_events($self->editor->event)
2837             unless $self->editor->create_action_hold_transit_copy($transit);
2838     } else {
2839         return $self->bail_on_events($self->editor->event)
2840             unless $self->editor->create_action_transit_copy($transit);
2841     }
2842
2843     # ensure the transit is returned to the caller
2844     $self->transit($transit);
2845
2846     $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2847     $self->update_copy;
2848     $self->checkin_changed(1);
2849 }
2850
2851
2852 sub hold_capture_is_possible {
2853     my $self = shift;
2854     my $copy = $self->copy;
2855
2856     # we've been explicitly told not to capture any holds
2857     return 0 if $self->capture eq 'nocapture';
2858
2859     # See if this copy can fulfill any holds
2860     my $hold = $holdcode->find_nearest_permitted_hold(
2861         $self->editor, $copy, $self->editor->requestor, 1 # check_only
2862     );
2863     return undef if ref $hold eq "HASH" and
2864         $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
2865     return $hold;
2866 }
2867
2868 sub reservation_capture_is_possible {
2869     my $self = shift;
2870     my $copy = $self->copy;
2871
2872     # we've been explicitly told not to capture any holds
2873     return 0 if $self->capture eq 'nocapture';
2874
2875     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2876     my $resv = $booking_ses->request(
2877         "open-ils.booking.reservations.could_capture",
2878         $self->editor->authtoken, $copy->barcode
2879     )->gather(1);
2880     $booking_ses->disconnect;
2881     if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
2882         $self->push_events($resv);
2883     } else {
2884         return $resv;
2885     }
2886 }
2887
2888 # returns true if the item was used (or may potentially be used 
2889 # in subsequent calls) to capture a hold.
2890 sub attempt_checkin_hold_capture {
2891     my $self = shift;
2892     my $copy = $self->copy;
2893
2894     # we've been explicitly told not to capture any holds
2895     return 0 if $self->capture eq 'nocapture';
2896
2897     # See if this copy can fulfill any holds
2898     my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold( 
2899         $self->editor, $copy, $self->editor->requestor );
2900
2901     if(!$hold) {
2902         $logger->debug("circulator: no potential permitted".
2903             "holds found for copy ".$copy->barcode);
2904         return 0;
2905     }
2906
2907     if($self->capture ne 'capture') {
2908         # see if this item is in a hold-capture-delay location
2909         my $location = $self->copy->location;
2910         if(!ref($location)) {
2911             $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2912             $self->copy->location($location);
2913         }
2914         if($U->is_true($location->hold_verify)) {
2915             $self->bail_on_events(
2916                 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
2917             return 1;
2918         }
2919     }
2920
2921     $self->retarget($retarget);
2922
2923     my $suppress_transit = 0;
2924     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2925         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2926         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2927             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2928             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2929                 $suppress_transit = 1;
2930                 $hold->pickup_lib($self->circ_lib);
2931             }
2932         }
2933     }
2934
2935     $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
2936
2937     $hold->current_copy($copy->id);
2938     $hold->capture_time('now');
2939     $self->put_hold_on_shelf($hold) 
2940         if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
2941
2942     # prevent DB errors caused by fetching 
2943     # holds from storage, and updating through cstore
2944     $hold->clear_fulfillment_time;
2945     $hold->clear_fulfillment_staff;
2946     $hold->clear_fulfillment_lib;
2947     $hold->clear_expire_time; 
2948     $hold->clear_cancel_time;
2949     $hold->clear_prev_check_time unless $hold->prev_check_time;
2950
2951     $self->bail_on_events($self->editor->event)
2952         unless $self->editor->update_action_hold_request($hold);
2953     $self->hold($hold);
2954     $self->checkin_changed(1);
2955
2956     return 0 if $self->bail_out;
2957
2958     if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
2959
2960         if ($hold->hold_type eq 'R') {
2961             $copy->status(OILS_COPY_STATUS_CATALOGING);
2962             $hold->fulfillment_time('now');
2963             $self->noop(1); # Block other transit/hold checks
2964             $self->bail_on_events($self->editor->event)
2965                 unless $self->editor->update_action_hold_request($hold);
2966         } else {
2967             # This hold was captured in the correct location
2968             $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2969             $self->push_events(OpenILS::Event->new('SUCCESS'));
2970
2971             #$self->do_hold_notify($hold->id);
2972             $self->notify_hold($hold->id);
2973         }
2974
2975     } else {
2976     
2977         # Hold needs to be picked up elsewhere.  Build a hold
2978         # transit and route the item.
2979         $self->checkin_build_hold_transit();
2980         $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2981         return 0 if $self->bail_out;
2982         $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
2983     }
2984
2985     # make sure we save the copy status
2986     $self->update_copy;
2987     return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
2988     return 1;
2989 }
2990
2991 sub attempt_checkin_reservation_capture {
2992     my $self = shift;
2993     my $copy = $self->copy;
2994
2995     # we've been explicitly told not to capture any holds
2996     return 0 if $self->capture eq 'nocapture';
2997
2998     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2999     my $evt = $booking_ses->request(
3000         "open-ils.booking.resources.capture_for_reservation",
3001         $self->editor->authtoken,
3002         $copy->barcode,
3003         1 # don't update copy - we probably have it locked
3004     )->gather(1);
3005     $booking_ses->disconnect;
3006
3007     if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3008         $logger->warn(
3009             "open-ils.booking.resources.capture_for_reservation " .
3010             "didn't return an event!"
3011         );
3012     } else {
3013         if (
3014             $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3015             $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3016         ) {
3017             # not-transferable is an error event we'll pass on the user
3018             $logger->warn("reservation capture attempted against non-transferable item");
3019             $self->push_events($evt);
3020             return 0;
3021         } elsif ($evt->{"textcode"} eq "SUCCESS") {
3022             # Re-retrieve copy as reservation capture may have changed
3023             # its status and whatnot.
3024             $logger->info(
3025                 "circulator: booking capture win on copy " . $self->copy->id
3026             );
3027             if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3028                 $logger->info(
3029                     "circulator: changing copy " . $self->copy->id .
3030                     "'s status from " . $self->copy->status . " to " .
3031                     $new_copy_status
3032                 );
3033                 $self->copy->status($new_copy_status);
3034                 $self->update_copy;
3035             }
3036             $self->reservation($evt->{"payload"}->{"reservation"});
3037
3038             if (exists $evt->{"payload"}->{"transit"}) {
3039                 $self->push_events(
3040                     new OpenILS::Event(
3041                         "ROUTE_ITEM",
3042                         "org" => $evt->{"payload"}->{"transit"}->dest
3043                     )
3044                 );
3045             }
3046             $self->checkin_changed(1);
3047             return 1;
3048         }
3049     }
3050     # other results are treated as "nothing to capture"
3051     return 0;
3052 }
3053
3054 sub do_hold_notify {
3055     my( $self, $holdid ) = @_;
3056
3057     my $e = new_editor(xact => 1);
3058     my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3059     $e->rollback;
3060     my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3061     $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3062
3063     $logger->info("circulator: running delayed hold notify process");
3064
3065 #   my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3066 #       hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3067
3068     my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3069         hold_id => $holdid, requestor => $self->editor->requestor);
3070
3071     $logger->debug("circulator: built hold notifier");
3072
3073     if(!$notifier->event) {
3074
3075         $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3076
3077         my $stat = $notifier->send_email_notify;
3078         if( $stat == '1' ) {
3079             $logger->info("circulator: hold notify succeeded for hold $holdid");
3080             return;
3081         } 
3082
3083         $logger->debug("circulator:  * hold notify cancelled or failed for hold $holdid");
3084
3085     } else {
3086         $logger->info("circulator: Not sending hold notification since the patron has no email address");
3087     }
3088 }
3089
3090 sub retarget_holds {
3091     my $self = shift;
3092     $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3093     my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3094     $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3095     # no reason to wait for the return value
3096     return;
3097 }
3098
3099 sub checkin_build_hold_transit {
3100     my $self = shift;
3101
3102    my $copy = $self->copy;
3103    my $hold = $self->hold;
3104    my $trans = Fieldmapper::action::hold_transit_copy->new;
3105
3106     $logger->debug("circulator: building hold transit for ".$copy->barcode);
3107
3108    $trans->hold($hold->id);
3109    $trans->source($self->circ_lib);
3110    $trans->dest($hold->pickup_lib);
3111    $trans->source_send_time("now");
3112    $trans->target_copy($copy->id);
3113
3114     # when the copy gets to its destination, it will recover
3115     # this status - put it onto the holds shelf
3116    $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3117
3118     return $self->bail_on_events($self->editor->event)
3119         unless $self->editor->create_action_hold_transit_copy($trans);
3120 }
3121
3122
3123
3124 sub process_received_transit {
3125     my $self = shift;
3126     my $copy = $self->copy;
3127     my $copyid = $self->copy->id;
3128
3129     my $status_name = $U->copy_status($copy->status)->name;
3130     $logger->debug("circulator: attempting transit receive on ".
3131         "copy $copyid. Copy status is $status_name");
3132
3133     my $transit = $self->transit;
3134
3135     # Check if we are in a transit suppress range
3136     my $suppress_transit = 0;
3137     if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3138         my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ?  'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3139         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3140         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3141             my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3142             if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3143                 $suppress_transit = 1;
3144                 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3145             }
3146         }
3147     }
3148     if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3149         # - this item is in-transit to a different location
3150         # - Or we are capturing holds as transits, so why create a new transit?
3151
3152         my $tid = $transit->id; 
3153         my $loc = $self->circ_lib;
3154         my $dest = $transit->dest;
3155
3156         $logger->info("circulator: Fowarding transit on copy which is destined ".
3157             "for a different location. transit=$tid, copy=$copyid, current ".
3158             "location=$loc, destination location=$dest");
3159
3160         my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3161
3162         # grab the associated hold object if available
3163         my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3164         $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3165
3166         return $self->bail_on_events($evt);
3167     }
3168
3169     # The transit is received, set the receive time
3170     $transit->dest_recv_time('now');
3171     $self->bail_on_events($self->editor->event)
3172         unless $self->editor->update_action_transit_copy($transit);
3173
3174     my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3175
3176     $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3177     $copy->status( $transit->copy_status );
3178     $self->update_copy();
3179     return if $self->bail_out;
3180
3181     my $ishold = 0;
3182     if($hold_transit) { 
3183         my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3184
3185         if ($hold) {
3186             # hold has arrived at destination, set shelf time
3187             $self->put_hold_on_shelf($hold);
3188             $self->bail_on_events($self->editor->event)
3189                 unless $self->editor->update_action_hold_request($hold);
3190             return if $self->bail_out;
3191
3192             $self->notify_hold($hold_transit->hold);
3193             $ishold = 1;
3194         } else {
3195             $hold_transit = undef;
3196             $self->cancelled_hold_transit(1);
3197             $self->reshelve_copy(1);
3198             $self->fake_hold_dest(0);
3199         }
3200     }
3201
3202     $self->push_events( 
3203         OpenILS::Event->new(
3204         'SUCCESS', 
3205         ishold => $ishold,
3206       payload => { transit => $transit, holdtransit => $hold_transit } ));
3207
3208     return $hold_transit;
3209 }
3210
3211
3212 # ------------------------------------------------------------------
3213 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3214 # ------------------------------------------------------------------
3215 sub put_hold_on_shelf {
3216     my($self, $hold) = @_;
3217     $hold->shelf_time('now');
3218     $hold->current_shelf_lib($self->circ_lib);
3219     $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3220     return undef;
3221 }
3222
3223 sub handle_fines {
3224    my $self = shift;
3225    my $reservation = shift;
3226    my $dt_parser = DateTime::Format::ISO8601->new;
3227
3228    my $obj = $reservation ? $self->reservation : $self->circ;
3229
3230     my $lost_bill_opts = $self->lost_bill_options;
3231     my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3232     # first, restore any voided overdues for lost, if needed
3233     if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3234         my $restore_od = $U->ou_ancestor_setting_value(
3235             $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3236             $self->editor) || 0;
3237         $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3238             if $restore_od;
3239     }
3240
3241     # next, handle normal overdue generation and apply stop_fines
3242     # XXX reservations don't have stop_fines
3243     # TODO revisit booking_reservation re: stop_fines support
3244     if ($reservation or !$obj->stop_fines) {
3245         my $skip_for_grace;
3246
3247         # This is a crude check for whether we are in a grace period. The code
3248         # in generate_fines() does a more thorough job, so this exists solely
3249         # as a small optimization, and might be better off removed.
3250
3251         # If we have a grace period
3252         if($obj->can('grace_period')) {
3253             # Parse out the due date
3254             my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($obj->due_date) );
3255             # Add the grace period to the due date
3256             $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($obj->grace_period));
3257             # Don't generate fines on circs still in grace period
3258             $skip_for_grace = $due_date > DateTime->now;
3259         }
3260         $CC->generate_fines({circs => [$obj], editor => $self->editor})
3261             unless $skip_for_grace;
3262
3263         if (!$reservation and !$obj->stop_fines) {
3264             $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3265             $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3266             $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3267             $obj->stop_fines_time('now');
3268             $obj->stop_fines_time($self->backdate) if $self->backdate;
3269             $self->editor->update_action_circulation($obj);
3270         }
3271     }
3272
3273     # finally, handle voiding of lost item and processing fees
3274     if ($self->needs_lost_bill_handling) {
3275         my $void_cost = $U->ou_ancestor_setting_value(
3276             $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3277             $self->editor) || 0;
3278         my $void_proc_fee = $U->ou_ancestor_setting_value(
3279             $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3280             $self->editor) || 0;
3281         $self->checkin_handle_lost_or_lo_now_found(
3282             $lost_bill_opts->{void_cost_btype},
3283             $lost_bill_opts->{is_longoverdue}) if $void_cost;
3284         $self->checkin_handle_lost_or_lo_now_found(
3285             $lost_bill_opts->{void_fee_btype},
3286             $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3287     }
3288
3289    return undef;
3290 }
3291
3292 sub checkin_handle_circ_start {
3293    my $self = shift;
3294    my $circ = $self->circ;
3295    my $copy = $self->copy;
3296    my $evt;
3297    my $obt;
3298
3299    $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3300
3301    # backdate the circ if necessary
3302    if($self->backdate) {
3303         my $evt = $self->checkin_handle_backdate;
3304         return $self->bail_on_events($evt) if $evt;
3305    }
3306
3307     # Set the checkin vars since we have the item
3308     $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3309
3310     # capture the true scan time for back-dated checkins
3311     $circ->checkin_scan_time('now');
3312
3313     $circ->checkin_staff($self->editor->requestor->id);
3314     $circ->checkin_lib($self->circ_lib);
3315     $circ->checkin_workstation($self->editor->requestor->wsid);
3316
3317     my $circ_lib = (ref $self->copy->circ_lib) ?  
3318         $self->copy->circ_lib->id : $self->copy->circ_lib;
3319     my $stat = $U->copy_status($self->copy->status)->id;
3320
3321     if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3322         # we will now handle lost fines, but the copy will retain its 'lost'
3323         # status if it needs to transit home unless lost_immediately_available
3324         # is true
3325         #
3326         # if we decide to also delay fine handling until the item arrives home,
3327         # we will need to call lost fine handling code both when checking items
3328         # in and also when receiving transits
3329         $self->checkin_handle_lost($circ_lib);
3330     } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3331         # same process as above.
3332         $self->checkin_handle_long_overdue($circ_lib);
3333     } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3334         $logger->info("circulator: not updating copy status on checkin because copy is missing");
3335     } else {
3336         $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3337         $self->update_copy;
3338     }
3339
3340     return undef;
3341 }
3342
3343 sub checkin_handle_circ_finish {
3344     my $self = shift;
3345     my $e = $self->editor;
3346     my $circ = $self->circ;
3347
3348     # Do one last check before the final circulation update to see 
3349     # if the xact_finish value should be set or not.
3350     #
3351     # The underlying money.billable_xact may have been updated to
3352     # reflect a change in xact_finish during checkin bills handling, 
3353     # however we can't simply refresh the circulation from the DB,
3354     # because other changes may be pending.  Instead, reproduce the
3355     # xact_finish check here.  It won't hurt to do it again.
3356
3357     my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3358     if ($sum) { # is this test still needed?
3359
3360         my $balance = $sum->balance_owed;
3361
3362         if ($balance == 0) {
3363             $circ->xact_finish('now');
3364         } else {
3365             $circ->clear_xact_finish;
3366         }
3367
3368         $logger->info("circulator: $balance is owed on this circulation");
3369     }
3370
3371     return $self->bail_on_events($e->event)
3372         unless $e->update_action_circulation($circ);
3373
3374     return undef;
3375 }
3376
3377 # ------------------------------------------------------------------
3378 # See if we need to void billings, etc. for lost checkin
3379 # ------------------------------------------------------------------
3380 sub checkin_handle_lost {
3381     my $self = shift;
3382     my $circ_lib = shift;
3383
3384     my $max_return = $U->ou_ancestor_setting_value($circ_lib, 
3385         OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3386
3387     $self->lost_bill_options({
3388         circ_lib => $circ_lib,
3389         ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3390         ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3391         ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3392         void_cost_btype => 3, 
3393         void_fee_btype => 4 
3394     });
3395
3396     return $self->checkin_handle_lost_or_longoverdue(
3397         circ_lib => $circ_lib,
3398         max_return => $max_return,
3399         ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3400         ous_use_last_activity => undef # not supported for LOST checkin
3401     );
3402 }
3403
3404 # ------------------------------------------------------------------
3405 # See if we need to void billings, etc. for long-overdue checkin
3406 # note: not using constants below since they serve little purpose 
3407 # for single-use strings that are descriptive in their own right 
3408 # and mostly just complicate debugging.
3409 # ------------------------------------------------------------------
3410 sub checkin_handle_long_overdue {
3411     my $self = shift;
3412     my $circ_lib = shift;
3413
3414     $logger->info("circulator: processing long-overdue checkin...");
3415
3416     my $max_return = $U->ou_ancestor_setting_value($circ_lib, 
3417         'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3418
3419     $self->lost_bill_options({
3420         circ_lib => $circ_lib,
3421         ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3422         ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3423         is_longoverdue => 1,
3424         ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3425         void_cost_btype => 10,
3426         void_fee_btype => 11
3427     });
3428
3429     return $self->checkin_handle_lost_or_longoverdue(
3430         circ_lib => $circ_lib,
3431         max_return => $max_return,
3432         ous_immediately_available => 'circ.longoverdue_immediately_available',
3433         ous_use_last_activity => 
3434             'circ.longoverdue.use_last_activity_date_on_return'
3435     )
3436 }
3437
3438 # last billing activity is last payment time, last billing time, or the 
3439 # circ due date.  If the relevant "use last activity" org unit setting is 
3440 # false/unset, then last billing activity is always the due date.
3441 sub get_circ_last_billing_activity {
3442     my $self = shift;
3443     my $circ_lib = shift;
3444     my $setting = shift;
3445     my $date = $self->circ->due_date;
3446
3447     return $date unless $setting and 
3448         $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3449
3450     my $xact = $self->editor->retrieve_money_billable_transaction([
3451         $self->circ->id,
3452         {flesh => 1, flesh_fields => {mbt => ['summary']}}
3453     ]);
3454
3455     if ($xact->summary) {
3456         $date = $xact->summary->last_payment_ts || 
3457                 $xact->summary->last_billing_ts || 
3458                 $self->circ->due_date;
3459     }
3460
3461     return $date;
3462 }
3463
3464
3465 sub checkin_handle_lost_or_longoverdue {
3466     my ($self, %args) = @_;
3467
3468     my $circ = $self->circ;
3469     my $max_return = $args{max_return};
3470     my $circ_lib = $args{circ_lib};
3471
3472     if ($max_return) {
3473
3474         my $last_activity = 
3475             $self->get_circ_last_billing_activity(
3476                 $circ_lib, $args{ous_use_last_activity});
3477
3478         my $today = time();
3479         my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3480         $tm[5] -= 1 if $tm[5] > 0;
3481         my $due = timelocal(int($tm[1]), int($tm[2]), 
3482             int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3483
3484         my $last_chance = 
3485             OpenSRF::Utils->interval_to_seconds($max_return) + int($due);
3486
3487         $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3488             "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3489                 "DUE: $due LAST: $last_chance");
3490
3491         $max_return = 0 if $today < $last_chance;
3492     }
3493
3494
3495     if ($max_return) {
3496
3497         $logger->info("circulator: check-in of lost/lo item exceeds max ". 
3498             "return interval.  skipping fine/fee voiding, etc.");
3499
3500     } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3501
3502         $logger->info("circulator: check-in of lost/lo item having a balance ".
3503             "of zero, skipping fine/fee voiding and reinstatement.");
3504
3505     } else { # within max-return interval or no interval defined
3506
3507         $logger->info("circulator: check-in of lost/lo item is within the ".
3508             "max return interval (or no interval is defined).  Proceeding ".
3509             "with fine/fee voiding, etc.");
3510
3511         $self->needs_lost_bill_handling(1);
3512     }
3513
3514     if ($circ_lib != $self->circ_lib) {
3515         # if the item is not home, check to see if we want to retain the
3516         # lost/longoverdue status at this point in the process
3517
3518         my $immediately_available = $U->ou_ancestor_setting_value($circ_lib, 
3519             $args{ous_immediately_available}, $self->editor) || 0;
3520
3521         if ($immediately_available) {
3522             # item status does not need to be retained, so give it a