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