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