Have amnesty mode override backdate for voiding
[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     # See if the user has any penalties applied that prevent hold fulfillment
1486     my $pens = $self->editor->json_query({
1487         select => {csp => ['name', 'label']},
1488         from => {ausp => {csp => {}}},
1489         where => {
1490             '+ausp' => {
1491                 usr => $self->patron->id,
1492                 org_unit => $U->get_org_full_path($self->circ_lib),
1493                 '-or' => [
1494                     {stop_date => undef},
1495                     {stop_date => {'>' => 'now'}}
1496                 ]
1497             },
1498             '+csp' => {block_list => {'like' => '%FULFILL%'}}
1499         }
1500     });
1501
1502     return 0 unless @$pens;
1503
1504     for my $pen (@$pens) {
1505         $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1506         my $event = OpenILS::Event->new($pen->{name});
1507         $event->{desc} = $pen->{label};
1508         $self->push_events($event);
1509     }
1510
1511     $self->override_events;
1512     return $self->bail_out;
1513 }
1514
1515
1516 # ------------------------------------------------------------------------------
1517 # When an item is checked out, see if we can fulfill a hold for this patron
1518 # ------------------------------------------------------------------------------
1519 sub handle_checkout_holds {
1520    my $self    = shift;
1521    my $copy    = $self->copy;
1522    my $patron  = $self->patron;
1523
1524    my $e = $self->editor;
1525    $self->fulfilled_holds([]);
1526
1527    # non-cats can't fulfill a hold
1528    return if $self->is_noncat;
1529
1530     my $hold = $e->search_action_hold_request({   
1531         current_copy        => $copy->id , 
1532         cancel_time         => undef, 
1533         fulfillment_time    => undef,
1534         '-or' => [
1535             {expire_time => undef},
1536             {expire_time => {'>' => 'now'}}
1537         ]
1538     })->[0];
1539
1540     if($hold and $hold->usr != $patron->id) {
1541         # reset the hold since the copy is now checked out
1542     
1543         $logger->info("circulator: un-targeting hold ".$hold->id.
1544             " because copy ".$copy->id." is getting checked out");
1545
1546         $hold->clear_prev_check_time; 
1547         $hold->clear_current_copy;
1548         $hold->clear_capture_time;
1549         $hold->clear_shelf_time;
1550         $hold->clear_shelf_expire_time;
1551         $hold->clear_current_shelf_lib;
1552
1553         return $self->bail_on_event($e->event)
1554             unless $e->update_action_hold_request($hold);
1555
1556         $hold = undef;
1557     }
1558
1559     unless($hold) {
1560         $hold = $self->find_related_user_hold($copy, $patron) or return;
1561         $logger->info("circulator: found related hold to fulfill in checkout");
1562     }
1563
1564     return if $self->check_hold_fulfill_blocks;
1565
1566     $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1567
1568     # if the hold was never officially captured, capture it.
1569     $hold->current_copy($copy->id);
1570     $hold->capture_time('now') unless $hold->capture_time;
1571     $hold->fulfillment_time('now');
1572     $hold->fulfillment_staff($e->requestor->id);
1573     $hold->fulfillment_lib($self->circ_lib);
1574
1575     return $self->bail_on_events($e->event)
1576         unless $e->update_action_hold_request($hold);
1577
1578     return $self->fulfilled_holds([$hold->id]);
1579 }
1580
1581
1582 # ------------------------------------------------------------------------------
1583 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1584 # the patron directly targets the checked out item, see if there is another hold 
1585 # for the patron that could be fulfilled by the checked out item.  Fulfill the
1586 # oldest hold and only fulfill 1 of them.
1587
1588 # For "another hold":
1589 #
1590 # First, check for one that the copy matches via hold_copy_map, ensuring that
1591 # *any* hold type that this copy could fill may end up filled.
1592 #
1593 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1594 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1595 # that are non-requestable to count as capturing those hold types.
1596 # ------------------------------------------------------------------------------
1597 sub find_related_user_hold {
1598     my($self, $copy, $patron) = @_;
1599     my $e = $self->editor;
1600
1601     # holds on precat copies are always copy-level, so this call will
1602     # always return undef.  Exit early.
1603     return undef if $self->is_precat;
1604
1605     return undef unless $U->ou_ancestor_setting_value(        
1606         $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1607
1608     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1609     my $args = {
1610         select => {ahr => ['id']}, 
1611         from => {
1612             ahr => {
1613                 ahcm => {
1614                     field => 'hold',
1615                     fkey => 'id'
1616                 },
1617                 acp => {
1618                     field => 'id', 
1619                     fkey => 'current_copy',
1620                     type => 'left' # there may be no current_copy
1621                 }
1622             }
1623         }, 
1624         where => {
1625             '+ahr' => {
1626                 usr => $patron->id,
1627                 fulfillment_time => undef,
1628                 cancel_time => undef,
1629                '-or' => [
1630                     {expire_time => undef},
1631                     {expire_time => {'>' => 'now'}}
1632                 ]
1633             },
1634             '+ahcm' => {
1635                 target_copy => $self->copy->id
1636             },
1637             '+acp' => {
1638                 '-or' => [
1639                     {id => undef}, # left-join copy may be nonexistent
1640                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1641                 ]
1642             }
1643         },
1644         order_by => {ahr => {request_time => {direction => 'asc'}}},
1645         limit => 1
1646     };
1647
1648     my $hold_info = $e->json_query($args)->[0];
1649     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1650     return undef if $U->ou_ancestor_setting_value(        
1651         $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1652
1653     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1654     $args = {
1655         select => {ahr => ['id']}, 
1656         from => {
1657             ahr => {
1658                 acp => {
1659                     field => 'id', 
1660                     fkey => 'current_copy',
1661                     type => 'left' # there may be no current_copy
1662                 }
1663             }
1664         }, 
1665         where => {
1666             '+ahr' => {
1667                 usr => $patron->id,
1668                 fulfillment_time => undef,
1669                 cancel_time => undef,
1670                '-or' => [
1671                     {expire_time => undef},
1672                     {expire_time => {'>' => 'now'}}
1673                 ]
1674             },
1675             '-or' => [
1676                 {
1677                     '+ahr' => { 
1678                         hold_type => 'V',
1679                         target => $self->volume->id
1680                     }
1681                 },
1682                 { 
1683                     '+ahr' => { 
1684                         hold_type => 'T',
1685                         target => $self->title->id
1686                     }
1687                 },
1688             ],
1689             '+acp' => {
1690                 '-or' => [
1691                     {id => undef}, # left-join copy may be nonexistent
1692                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1693                 ]
1694             }
1695         },
1696         order_by => {ahr => {request_time => {direction => 'asc'}}},
1697         limit => 1
1698     };
1699
1700     $hold_info = $e->json_query($args)->[0];
1701     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1702     return undef;
1703 }
1704
1705
1706 sub run_checkout_scripts {
1707     my $self = shift;
1708     my $nobail = shift;
1709
1710     my $evt;
1711
1712     my $duration;
1713     my $recurring;
1714     my $max_fine;
1715     my $hard_due_date;
1716     my $duration_name;
1717     my $recurring_name;
1718     my $max_fine_name;
1719     my $hard_due_date_name;
1720
1721     $self->run_indb_circ_test();
1722     $duration = $self->circ_matrix_matchpoint->duration_rule;
1723     $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
1724     $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
1725     $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1726
1727     $duration_name = $duration->name if $duration;
1728     if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
1729
1730         unless($duration) {
1731             ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
1732             return $self->bail_on_events($evt) if ($evt && !$nobail);
1733         
1734             ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
1735             return $self->bail_on_events($evt) if ($evt && !$nobail);
1736         
1737             ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
1738             return $self->bail_on_events($evt) if ($evt && !$nobail);
1739
1740             if($hard_due_date_name) {
1741                 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
1742                 return $self->bail_on_events($evt) if ($evt && !$nobail);
1743             }
1744         }
1745
1746     } else {
1747
1748         # The item circulates with an unlimited duration
1749         $duration   = undef;
1750         $recurring  = undef;
1751         $max_fine   = undef;
1752         $hard_due_date = undef;
1753     }
1754
1755    $self->duration_rule($duration);
1756    $self->recurring_fines_rule($recurring);
1757    $self->max_fine_rule($max_fine);
1758    $self->hard_due_date($hard_due_date);
1759 }
1760
1761
1762 sub build_checkout_circ_object {
1763     my $self = shift;
1764
1765    my $circ       = Fieldmapper::action::circulation->new;
1766    my $duration   = $self->duration_rule;
1767    my $max        = $self->max_fine_rule;
1768    my $recurring  = $self->recurring_fines_rule;
1769    my $hard_due_date    = $self->hard_due_date;
1770    my $copy       = $self->copy;
1771    my $patron     = $self->patron;
1772    my $duration_date_ceiling;
1773    my $duration_date_ceiling_force;
1774
1775     if( $duration ) {
1776
1777         my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
1778         $duration_date_ceiling = $policy->{duration_date_ceiling};
1779         $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
1780
1781         my $dname = $duration->name;
1782         my $mname = $max->name;
1783         my $rname = $recurring->name;
1784         my $hdname = ''; 
1785         if($hard_due_date) {
1786             $hdname = $hard_due_date->name;
1787         }
1788
1789         $logger->debug("circulator: building circulation ".
1790             "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
1791     
1792         $circ->duration($policy->{duration});
1793         $circ->recurring_fine($policy->{recurring_fine});
1794         $circ->duration_rule($duration->name);
1795         $circ->recurring_fine_rule($recurring->name);
1796         $circ->max_fine_rule($max->name);
1797         $circ->max_fine($policy->{max_fine});
1798         $circ->fine_interval($recurring->recurrence_interval);
1799         $circ->renewal_remaining($duration->max_renewals);
1800         $circ->grace_period($policy->{grace_period});
1801
1802     } else {
1803
1804         $logger->info("circulator: copy found with an unlimited circ duration");
1805         $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
1806         $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1807         $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1808         $circ->renewal_remaining(0);
1809         $circ->grace_period(0);
1810     }
1811
1812    $circ->target_copy( $copy->id );
1813    $circ->usr( $patron->id );
1814    $circ->circ_lib( $self->circ_lib );
1815    $circ->workstation($self->editor->requestor->wsid) 
1816     if defined $self->editor->requestor->wsid;
1817
1818     # renewals maintain a link to the parent circulation
1819     $circ->parent_circ($self->parent_circ);
1820
1821    if( $self->is_renewal ) {
1822       $circ->opac_renewal('t') if $self->opac_renewal;
1823       $circ->phone_renewal('t') if $self->phone_renewal;
1824       $circ->desk_renewal('t') if $self->desk_renewal;
1825       $circ->renewal_remaining($self->renewal_remaining);
1826       $circ->circ_staff($self->editor->requestor->id);
1827    }
1828
1829
1830     # if the user provided an overiding checkout time,
1831     # (e.g. the checkout really happened several hours ago), then
1832     # we apply that here.  Does this need a perm??
1833     $circ->xact_start(cleanse_ISO8601($self->checkout_time))
1834         if $self->checkout_time;
1835
1836     # if a patron is renewing, 'requestor' will be the patron
1837     $circ->circ_staff($self->editor->requestor->id);
1838     $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
1839
1840     $self->circ($circ);
1841 }
1842
1843 sub do_reservation_pickup {
1844     my $self = shift;
1845
1846     $self->log_me("do_reservation_pickup()");
1847
1848     $self->reservation->pickup_time('now');
1849
1850     if (
1851         $self->reservation->current_resource &&
1852         $U->is_true($self->reservation->target_resource_type->catalog_item)
1853     ) {
1854         # We used to try to set $self->copy and $self->patron here,
1855         # but that should already be done.
1856
1857         $self->run_checkout_scripts(1);
1858
1859         my $duration   = $self->duration_rule;
1860         my $max        = $self->max_fine_rule;
1861         my $recurring  = $self->recurring_fines_rule;
1862
1863         if ($duration && $max && $recurring) {
1864             my $policy = $self->get_circ_policy($duration, $recurring, $max);
1865
1866             my $dname = $duration->name;
1867             my $mname = $max->name;
1868             my $rname = $recurring->name;
1869
1870             $logger->debug("circulator: updating reservation ".
1871                 "with duration=$dname, maxfine=$mname, recurring=$rname");
1872
1873             $self->reservation->fine_amount($policy->{recurring_fine});
1874             $self->reservation->max_fine($policy->{max_fine});
1875             $self->reservation->fine_interval($recurring->recurrence_interval);
1876         }
1877
1878         $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1879         $self->update_copy();
1880
1881     } else {
1882         $self->reservation->fine_amount(
1883             $self->reservation->target_resource_type->fine_amount
1884         );
1885         $self->reservation->max_fine(
1886             $self->reservation->target_resource_type->max_fine
1887         );
1888         $self->reservation->fine_interval(
1889             $self->reservation->target_resource_type->fine_interval
1890         );
1891     }
1892
1893     $self->update_reservation();
1894 }
1895
1896 sub do_reservation_return {
1897     my $self = shift;
1898     my $request = shift;
1899
1900     $self->log_me("do_reservation_return()");
1901
1902     if (not ref $self->reservation) {
1903         my ($reservation, $evt) =
1904             $U->fetch_booking_reservation($self->reservation);
1905         return $self->bail_on_events($evt) if $evt;
1906         $self->reservation($reservation);
1907     }
1908
1909     $self->handle_fines(1);
1910     $self->reservation->return_time('now');
1911     $self->update_reservation();
1912     $self->reshelve_copy if $self->copy;
1913
1914     if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
1915         $self->copy( $self->reservation->current_resource->catalog_item );
1916     }
1917 }
1918
1919 sub booking_adjusted_due_date {
1920     my $self = shift;
1921     my $circ = $self->circ;
1922     my $copy = $self->copy;
1923
1924     return undef unless $self->use_booking;
1925
1926     my $changed;
1927
1928     if( $self->due_date ) {
1929
1930         return $self->bail_on_events($self->editor->event)
1931             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
1932
1933        $circ->due_date(cleanse_ISO8601($self->due_date));
1934
1935     } else {
1936
1937         return unless $copy and $circ->due_date;
1938     }
1939
1940     my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
1941     if (@$booking_items) {
1942         my $booking_item = $booking_items->[0];
1943         my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
1944
1945         my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
1946         my $shorten_circ_setting = $resource_type->elbow_room ||
1947             $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
1948             '0 seconds';
1949
1950         my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
1951         my $bookings = $booking_ses->request(
1952             'open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken,
1953             { resource => $booking_item->id, search_start => 'now', search_end => $circ->due_date, fields => { cancel_time => undef, return_time => undef}}
1954         )->gather(1);
1955         $booking_ses->disconnect;
1956         
1957         my $dt_parser = DateTime::Format::ISO8601->new;
1958         my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
1959
1960         for my $bid (@$bookings) {
1961
1962             my $booking = $self->editor->retrieve_booking_reservation( $bid );
1963
1964             my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
1965             my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
1966
1967             return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
1968                 if ($booking_start < DateTime->now);
1969
1970
1971             if ($U->is_true($stop_circ_setting)) {
1972                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ); 
1973             } else {
1974                 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
1975                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now); 
1976             }
1977             
1978             # We set the circ duration here only to affect the logic that will
1979             # later (in a DB trigger) mangle the time part of the due date to
1980             # 11:59pm. Having any circ duration that is not a whole number of
1981             # days is enough to prevent the "correction."
1982             my $new_circ_duration = $due_date->epoch - time;
1983             $new_circ_duration++ if $new_circ_duration % 86400 == 0;
1984             $circ->duration("$new_circ_duration seconds");
1985
1986             $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
1987             $changed = 1;
1988         }
1989
1990         return $self->bail_on_events($self->editor->event)
1991             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
1992     }
1993
1994     return $changed;
1995 }
1996
1997 sub apply_modified_due_date {
1998     my $self = shift;
1999     my $shift_earlier = shift;
2000     my $circ = $self->circ;
2001     my $copy = $self->copy;
2002
2003    if( $self->due_date ) {
2004
2005         return $self->bail_on_events($self->editor->event)
2006             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2007
2008       $circ->due_date(cleanse_ISO8601($self->due_date));
2009
2010    } else {
2011
2012       # if the due_date lands on a day when the location is closed
2013       return unless $copy and $circ->due_date;
2014
2015         #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2016
2017         # due-date overlap should be determined by the location the item
2018         # is checked out from, not the owning or circ lib of the item
2019         my $org = $self->circ_lib;
2020
2021       $logger->info("circulator: circ searching for closed date overlap on lib $org".
2022             " with an item due date of ".$circ->due_date );
2023
2024       my $dateinfo = $U->storagereq(
2025          'open-ils.storage.actor.org_unit.closed_date.overlap', 
2026             $org, $circ->due_date );
2027
2028       if($dateinfo) {
2029          $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2030             $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2031
2032             # XXX make the behavior more dynamic
2033             # for now, we just push the due date to after the close date
2034             if ($shift_earlier) {
2035                 $circ->due_date($dateinfo->{start});
2036             } else {
2037                 $circ->due_date($dateinfo->{end});
2038             }
2039       }
2040    }
2041 }
2042
2043
2044
2045 sub create_due_date {
2046     my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2047
2048     # if there is a raw time component (e.g. from postgres), 
2049     # turn it into an interval that interval_to_seconds can parse
2050     $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2051
2052     # for now, use the server timezone.  TODO: use workstation org timezone
2053     my $due_date = DateTime->now(time_zone => 'local');
2054     $due_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)) if $start_time;
2055
2056     # add the circ duration
2057     $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2058
2059     if($date_ceiling) {
2060         my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2061         if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2062             $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2063             $due_date = $cdate;
2064         }
2065     }
2066
2067     # return ISO8601 time with timezone
2068     return $due_date->strftime('%FT%T%z');
2069 }
2070
2071
2072
2073 sub make_precat_copy {
2074     my $self = shift;
2075     my $copy = $self->copy;
2076
2077    if($copy) {
2078         $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2079
2080         $copy->editor($self->editor->requestor->id);
2081         $copy->edit_date('now');
2082         $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2083         $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2084         $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2085         $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2086         $self->update_copy();
2087         return;
2088    }
2089
2090     $logger->info("circulator: Creating a new precataloged ".
2091         "copy in checkout with barcode " . $self->copy_barcode);
2092
2093     $copy = Fieldmapper::asset::copy->new;
2094     $copy->circ_lib($self->circ_lib);
2095     $copy->creator($self->editor->requestor->id);
2096     $copy->editor($self->editor->requestor->id);
2097     $copy->barcode($self->copy_barcode);
2098     $copy->call_number(OILS_PRECAT_CALL_NUMBER); 
2099     $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2100     $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2101
2102     $copy->dummy_title($self->dummy_title || "");
2103     $copy->dummy_author($self->dummy_author || "");
2104     $copy->dummy_isbn($self->dummy_isbn || "");
2105     $copy->circ_modifier($self->circ_modifier);
2106
2107
2108     # See if we need to override the circ_lib for the copy with a configured circ_lib
2109     # Setting is shortname of the org unit
2110     my $precat_circ_lib = $U->ou_ancestor_setting_value(
2111         $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2112
2113     if($precat_circ_lib) {
2114         my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2115
2116         if(!$org) {
2117             $self->bail_on_events($self->editor->event);
2118             return;
2119         }
2120
2121         $copy->circ_lib($org->id);
2122     }
2123
2124
2125     unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2126         $self->bail_out(1);
2127         $self->push_events($self->editor->event);
2128         return;
2129     }   
2130 }
2131
2132
2133 sub checkout_noncat {
2134     my $self = shift;
2135
2136     my $circ;
2137     my $evt;
2138
2139    my $lib      = $self->noncat_circ_lib || $self->circ_lib;
2140    my $count    = $self->noncat_count || 1;
2141    my $cotime   = cleanse_ISO8601($self->checkout_time) || "";
2142
2143    $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2144
2145    for(1..$count) {
2146
2147       ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2148          $self->editor->requestor->id, 
2149             $self->patron->id, 
2150             $lib, 
2151             $self->noncat_type, 
2152             $cotime,
2153             $self->editor );
2154
2155         if( $evt ) {
2156             $self->push_events($evt);
2157             $self->bail_out(1);
2158             return; 
2159         }
2160         $self->circ($circ);
2161    }
2162 }
2163
2164 # If a copy goes into transit and is then checked in before the transit checkin 
2165 # interval has expired, push an event onto the overridable events list.
2166 sub check_transit_checkin_interval {
2167     my $self = shift;
2168
2169     # only concerned with in-transit items
2170     return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2171
2172     # no interval, no problem
2173     my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2174     return unless $interval;
2175
2176     # capture the transit so we don't have to fetch it again later during checkin
2177     $self->transit(
2178         $self->editor->search_action_transit_copy(
2179             {target_copy => $self->copy->id, dest_recv_time => undef}
2180         )->[0]
2181     ); 
2182
2183     # transit from X to X for whatever reason has no min interval
2184     return if $self->transit->source == $self->transit->dest;
2185
2186     my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2187     my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2188     my $horizon = $t_start->add(seconds => $seconds);
2189
2190     # See if we are still within the transit checkin forbidden range
2191     $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK')) 
2192         if $horizon > DateTime->now;
2193 }
2194
2195 # Retarget local holds at checkin
2196 sub checkin_retarget {
2197     my $self = shift;
2198     return unless $self->retarget_mode =~ m/retarget/; # Retargeting?
2199     return unless $self->is_checkin; # Renewals need not be checked
2200     return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2201     return if $self->is_precat; # No holds for precats
2202     return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2203     return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2204     my $status = $U->copy_status($self->copy->status);
2205     return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2206     # Specifically target items that are likely new (by status ID)
2207     return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2208     my $location = $self->copy->location;
2209     if(!ref($location)) {
2210         $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2211         $self->copy->location($location);
2212     }
2213     return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2214
2215     # Fetch holds for the bib
2216     my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2217                     $self->editor->authtoken,
2218                     $self->title->id,
2219                     {
2220                         capture_time => undef, # No touching captured holds
2221                         frozen => 'f', # Don't bother with frozen holds
2222                         pickup_lib => $self->circ_lib # Only holds actually here
2223                     }); 
2224
2225     # Error? Skip the step.
2226     return if exists $result->{"ilsevent"};
2227
2228     # Assemble holds
2229     my $holds = [];
2230     foreach my $holdlist (keys %{$result}) {
2231         push @$holds, @{$result->{$holdlist}};
2232     }
2233
2234     return if scalar(@$holds) == 0; # No holds, no retargeting
2235
2236     # Check for parts on this copy
2237     my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2238     my %parts_hash = ();
2239     %parts_hash = map {$_->part, 1} @$parts if @$parts;
2240
2241     # Loop over holds in request-ish order
2242     # Stage 1: Get them into request-ish order
2243     # Also grab type and target for skipping low hanging ones
2244     $result = $self->editor->json_query({
2245         "select" => { "ahr" => ["id", "hold_type", "target"] },
2246         "from" => { "ahr" => { "au" => { "fkey" => "usr",  "join" => "pgt"} } },
2247         "where" => { "id" => $holds },
2248         "order_by" => [
2249             { "class" => "pgt", "field" => "hold_priority"},
2250             { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2251             { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2252             { "class" => "ahr", "field" => "request_time"}
2253         ]
2254     });
2255
2256     # Stage 2: Loop!
2257     if (ref $result eq "ARRAY" and scalar @$result) {
2258         foreach (@{$result}) {
2259             # Copy level, but not this copy?
2260             next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2261                 and $_->{target} != $self->copy->id);
2262             # Volume level, but not this volume?
2263             next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2264             if(@$parts) { # We have parts?
2265                 # Skip title holds
2266                 next if ($_->{hold_type} eq 'T');
2267                 # Skip part holds for parts not on this copy
2268                 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2269             } else {
2270                 # No parts, no part holds
2271                 next if ($_->{hold_type} eq 'P');
2272             }
2273             # So much for easy stuff, attempt a retarget!
2274             my $tresult = $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $_->{id}, $self->copy->id);
2275             if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2276                 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2277             }
2278         }
2279     }
2280 }
2281
2282 sub do_checkin {
2283     my $self = shift;
2284     $self->log_me("do_checkin()");
2285
2286     return $self->bail_on_events(
2287         OpenILS::Event->new('ASSET_COPY_NOT_FOUND')) 
2288         unless $self->copy;
2289
2290     $self->check_transit_checkin_interval;
2291     $self->checkin_retarget;
2292
2293     # the renew code and mk_env should have already found our circulation object
2294     unless( $self->circ ) {
2295
2296         my $circs = $self->editor->search_action_circulation(
2297             { target_copy => $self->copy->id, checkin_time => undef });
2298
2299         $self->circ($$circs[0]);
2300
2301         # for now, just warn if there are multiple open circs on a copy
2302         $logger->warn("circulator: we have ".scalar(@$circs).
2303             " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2304     }
2305
2306     my $stat = $U->copy_status($self->copy->status)->id;
2307
2308     # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2309     # differently if they are already paid for.  We need to check for this
2310     # early since overdue generation is potentially affected.
2311     my $dont_change_lost_zero = 0;
2312     if ($stat == OILS_COPY_STATUS_LOST
2313         || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2314         || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2315
2316         # LOST fine settings are controlled by the copy's circ lib, not the the
2317         # circulation's
2318         my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2319                 $self->copy->circ_lib->id : $self->copy->circ_lib;
2320         $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2321             $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2322             $self->editor) || 0;
2323
2324         if ($dont_change_lost_zero) {
2325             my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2326             $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2327         }
2328
2329         $self->dont_change_lost_zero($dont_change_lost_zero);
2330     }
2331
2332     if( $self->checkin_check_holds_shelf() ) {
2333         $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2334         $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2335         if($self->fake_hold_dest) {
2336             $self->hold->pickup_lib($self->circ_lib);
2337         }
2338         $self->checkin_flesh_events;
2339         return;
2340     }
2341
2342     unless( $self->is_renewal ) {
2343         return $self->bail_on_events($self->editor->event)
2344             unless $self->editor->allowed('COPY_CHECKIN');
2345     }
2346
2347     $self->push_events($self->check_copy_alert());
2348     $self->push_events($self->check_checkin_copy_status());
2349
2350     # if the circ is marked as 'claims returned', add the event to the list
2351     $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2352         if ($self->circ and $self->circ->stop_fines 
2353                 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2354
2355     $self->check_circ_deposit();
2356
2357     # handle the overridable events 
2358     $self->override_events unless $self->is_renewal;
2359     return if $self->bail_out;
2360     
2361     if( $self->copy and !$self->transit ) {
2362         $self->transit(
2363             $self->editor->search_action_transit_copy(
2364                 { target_copy => $self->copy->id, dest_recv_time => undef }
2365             )->[0]
2366         ); 
2367     }
2368
2369     if( $self->circ ) {
2370         $self->checkin_handle_circ;
2371         return if $self->bail_out;
2372         $self->checkin_changed(1);
2373
2374         if (!$dont_change_lost_zero) {
2375             # if this circ is LOST and we are configured to generate overdue
2376             # fines for lost items on checkin (to fill the gap between mark
2377             # lost time and when the fines would have naturally stopped), then
2378             # stop_fines is no longer valid and should be cleared.
2379             #
2380             # stop_fines will be set again during the handle_fines() stage.
2381             # XXX should this setting come from the copy circ lib (like other
2382             # LOST settings), instead of the circulation circ lib?
2383             if ($stat == OILS_COPY_STATUS_LOST) {
2384                 $self->circ->clear_stop_fines if
2385                     $U->ou_ancestor_setting_value(
2386                         $self->circ_lib,
2387                         OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2388                         $self->editor
2389                     );
2390             }
2391
2392             # handle fines for this circ, including overdue gen if needed
2393             $self->handle_fines;
2394         }
2395     } elsif( $self->transit ) {
2396         my $hold_transit = $self->process_received_transit;
2397         $self->checkin_changed(1);
2398
2399         if( $self->bail_out ) { 
2400             $self->checkin_flesh_events;
2401             return;
2402         }
2403         
2404         if( my $e = $self->check_checkin_copy_status() ) {
2405             # If the original copy status is special, alert the caller
2406             my $ev = $self->events;
2407             $self->events([$e]);
2408             $self->override_events;
2409             return if $self->bail_out;
2410             $self->events($ev);
2411         }
2412
2413         if( $hold_transit or 
2414                 $U->copy_status($self->copy->status)->id 
2415                     == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2416
2417             my $hold;
2418             if( $hold_transit ) {
2419                $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2420             } else {
2421                    ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2422             }
2423
2424             $self->hold($hold);
2425
2426             if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2427
2428                 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2429                 $self->reshelve_copy(1);
2430                 $self->cancelled_hold_transit(1);
2431                 $self->notify_hold(0); # don't notify for cancelled holds
2432                 $self->fake_hold_dest(0);
2433                 return if $self->bail_out;
2434
2435             } elsif ($hold and $hold->hold_type eq 'R') {
2436
2437                 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2438                 $self->notify_hold(0); # No need to notify
2439                 $self->fake_hold_dest(0);
2440                 $self->noop(1); # Don't try and capture for other holds/transits now
2441                 $self->update_copy();
2442                 $hold->fulfillment_time('now');
2443                 $self->bail_on_events($self->editor->event)
2444                     unless $self->editor->update_action_hold_request($hold);
2445
2446             } else {
2447
2448                 # hold transited to correct location
2449                 if($self->fake_hold_dest) {
2450                     $hold->pickup_lib($self->circ_lib);
2451                 }
2452                 $self->checkin_flesh_events;
2453                 return;
2454             }
2455         } 
2456
2457     } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2458
2459         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2460             " that is in-transit, but there is no transit.. repairing");
2461         $self->reshelve_copy(1);
2462         return if $self->bail_out;
2463     }
2464
2465     if( $self->is_renewal ) {
2466         $self->finish_fines_and_voiding;
2467         return if $self->bail_out;
2468         $self->push_events(OpenILS::Event->new('SUCCESS'));
2469         return;
2470     }
2471
2472    # ------------------------------------------------------------------------------
2473    # Circulations and transits are now closed where necessary.  Now go on to see if
2474    # this copy can fulfill a hold or needs to be routed to a different location
2475    # ------------------------------------------------------------------------------
2476
2477     my $needed_for_something = 0; # formerly "needed_for_hold"
2478
2479     if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2480
2481         if (!$self->remote_hold) {
2482             if ($self->use_booking) {
2483                 my $potential_hold = $self->hold_capture_is_possible;
2484                 my $potential_reservation = $self->reservation_capture_is_possible;
2485
2486                 if ($potential_hold and $potential_reservation) {
2487                     $logger->info("circulator: item could fulfill either hold or reservation");
2488                     $self->push_events(new OpenILS::Event(
2489                         "HOLD_RESERVATION_CONFLICT",
2490                         "hold" => $potential_hold,
2491                         "reservation" => $potential_reservation
2492                     ));
2493                     return if $self->bail_out;
2494                 } elsif ($potential_hold) {
2495                     $needed_for_something =
2496                         $self->attempt_checkin_hold_capture;
2497                 } elsif ($potential_reservation) {
2498                     $needed_for_something =
2499                         $self->attempt_checkin_reservation_capture;
2500                 }
2501             } else {
2502                 $needed_for_something = $self->attempt_checkin_hold_capture;
2503             }
2504         }
2505         return if $self->bail_out;
2506     
2507         unless($needed_for_something) {
2508             my $circ_lib = (ref $self->copy->circ_lib) ? 
2509                     $self->copy->circ_lib->id : $self->copy->circ_lib;
2510     
2511             if( $self->remote_hold ) {
2512                 $circ_lib = $self->remote_hold->pickup_lib;
2513                 $logger->warn("circulator: Copy ".$self->copy->barcode.
2514                     " is on a remote hold's shelf, sending to $circ_lib");
2515             }
2516     
2517             $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2518
2519             my $suppress_transit = 0;
2520
2521             if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2522                 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2523                 if($suppress_transit_source && $suppress_transit_source->{value}) {
2524                     my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2525                     if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2526                         $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2527                         $suppress_transit = 1;
2528                     }
2529                 }
2530             }
2531  
2532             if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2533                 # copy is where it needs to be, either for hold or reshelving
2534     
2535                 $self->checkin_handle_precat();
2536                 return if $self->bail_out;
2537     
2538             } else {
2539                 # copy needs to transit "home", or stick here if it's a floating copy
2540                 my $can_float = 0;
2541                 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2542                     my $res = $self->editor->json_query(
2543                         {   from => [
2544                                 'evergreen.can_float',
2545                                 $self->copy->floating->id,
2546                                 $self->copy->circ_lib,
2547                                 $self->circ_lib
2548                             ]
2549                         }
2550                     );
2551                     $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res; 
2552                 }
2553                 if ($can_float) { # Yep, floating, stick here
2554                     $self->checkin_changed(1);
2555                     $self->copy->circ_lib( $self->circ_lib );
2556                     $self->update_copy;
2557                 } else {
2558                     my $bc = $self->copy->barcode;
2559                     $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2560                     $self->checkin_build_copy_transit($circ_lib);
2561                     return if $self->bail_out;
2562                     $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2563                 }
2564             }
2565         }
2566     } else { # no-op checkin
2567         if ($U->is_true( $self->copy->floating )) { # XXX floating items still stick where they are even with no-op checkin?
2568             $self->checkin_changed(1);
2569             $self->copy->circ_lib( $self->circ_lib );
2570             $self->update_copy;
2571         }
2572     }
2573
2574     if($self->claims_never_checked_out and 
2575             $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2576
2577         # the item was not supposed to be checked out to the user and should now be marked as missing
2578         $self->copy->status(OILS_COPY_STATUS_MISSING);
2579         $self->update_copy;
2580
2581     } else {
2582         $self->reshelve_copy unless $needed_for_something;
2583     }
2584
2585     return if $self->bail_out;
2586
2587     unless($self->checkin_changed) {
2588
2589         $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2590         my $stat = $U->copy_status($self->copy->status)->id;
2591
2592         $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2593          if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2594         $self->bail_out(1); # no need to commit anything
2595
2596     } else {
2597
2598         $self->push_events(OpenILS::Event->new('SUCCESS')) 
2599             unless @{$self->events};
2600     }
2601
2602     $self->finish_fines_and_voiding;
2603
2604     OpenILS::Utils::Penalty->calculate_penalties(
2605         $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2606
2607     $self->checkin_flesh_events;
2608     return;
2609 }
2610
2611 sub finish_fines_and_voiding {
2612     my $self = shift;
2613     return unless $self->circ;
2614
2615     return unless $self->backdate or $self->void_overdues;
2616
2617     # void overdues after fine generation to prevent concurrent DB access to overdue billings
2618     my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2619
2620     my $evt = $CC->void_or_zero_overdues(
2621         $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
2622
2623     return $self->bail_on_events($evt) if $evt;
2624
2625     # Make sure the circ is open or closed as necessary.
2626     $evt = $U->check_open_xact($self->editor, $self->circ->id);
2627     return $self->bail_on_events($evt) if $evt;
2628
2629     return undef;
2630 }
2631
2632
2633 # if a deposit was payed for this item, push the event
2634 sub check_circ_deposit {
2635     my $self = shift;
2636     return unless $self->circ;
2637     my $deposit = $self->editor->search_money_billing(
2638         {   btype => 5, 
2639             xact => $self->circ->id, 
2640             voided => 'f'
2641         }, {idlist => 1})->[0];
2642
2643     $self->push_events(OpenILS::Event->new(
2644         'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2645 }
2646
2647 sub reshelve_copy {
2648    my $self    = shift;
2649    my $force   = $self->force || shift;
2650    my $copy    = $self->copy;
2651
2652    my $stat = $U->copy_status($copy->status)->id;
2653
2654    if($force || (
2655       $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2656       $stat != OILS_COPY_STATUS_CATALOGING and
2657       $stat != OILS_COPY_STATUS_IN_TRANSIT and
2658       $stat != OILS_COPY_STATUS_RESHELVING  )) {
2659
2660         $copy->status( OILS_COPY_STATUS_RESHELVING );
2661             $self->update_copy;
2662             $self->checkin_changed(1);
2663     }
2664 }
2665
2666
2667 # Returns true if the item is at the current location
2668 # because it was transited there for a hold and the 
2669 # hold has not been fulfilled
2670 sub checkin_check_holds_shelf {
2671     my $self = shift;
2672     return 0 unless $self->copy;
2673
2674     return 0 unless 
2675         $U->copy_status($self->copy->status)->id ==
2676             OILS_COPY_STATUS_ON_HOLDS_SHELF;
2677
2678     # Attempt to clear shelf expired holds for this copy
2679     $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
2680         if($self->clear_expired);
2681
2682     # find the hold that put us on the holds shelf
2683     my $holds = $self->editor->search_action_hold_request(
2684         { 
2685             current_copy => $self->copy->id,
2686             capture_time => { '!=' => undef },
2687             fulfillment_time => undef,
2688             cancel_time => undef,
2689         }
2690     );
2691
2692     unless(@$holds) {
2693         $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
2694         $self->reshelve_copy(1);
2695         return 0;
2696     }
2697
2698     my $hold = $$holds[0];
2699
2700     $logger->info("circulator: we found a captured, un-fulfilled hold [".
2701         $hold->id. "] for copy ".$self->copy->barcode);
2702
2703     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2704         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2705         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2706             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2707             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2708                 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
2709                 $self->fake_hold_dest(1);
2710                 return 1;
2711             }
2712         }
2713     }
2714
2715     if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
2716         $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
2717         return 1;
2718     }
2719
2720     $logger->info("circulator: hold is not for here..");
2721     $self->remote_hold($hold);
2722     return 0;
2723 }
2724
2725
2726 sub checkin_handle_precat {
2727     my $self    = shift;
2728    my $copy    = $self->copy;
2729
2730    if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
2731         $copy->status(OILS_COPY_STATUS_CATALOGING);
2732         $self->update_copy();
2733         $self->checkin_changed(1);
2734         $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
2735    }
2736 }
2737
2738
2739 sub checkin_build_copy_transit {
2740     my $self            = shift;
2741     my $dest            = shift;
2742     my $copy       = $self->copy;
2743     my $transit    = Fieldmapper::action::transit_copy->new;
2744
2745     # if we are transiting an item to the shelf shelf, it's a hold transit
2746     if (my $hold = $self->remote_hold) {
2747         $transit = Fieldmapper::action::hold_transit_copy->new;
2748         $transit->hold($hold->id);
2749
2750         # the item is going into transit, remove any shelf-iness
2751         if ($hold->current_shelf_lib or $hold->shelf_time) {
2752             $hold->clear_current_shelf_lib;
2753             $hold->clear_shelf_time;
2754             return $self->bail_on_events($self->editor->event)
2755                 unless $self->editor->update_action_hold_request($hold);
2756         }
2757     }
2758
2759     #$dest  ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
2760     $logger->info("circulator: transiting copy to $dest");
2761
2762     $transit->source($self->circ_lib);
2763     $transit->dest($dest);
2764     $transit->target_copy($copy->id);
2765     $transit->source_send_time('now');
2766     $transit->copy_status( $U->copy_status($copy->status)->id );
2767
2768     $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
2769
2770     if ($self->remote_hold) {
2771         return $self->bail_on_events($self->editor->event)
2772             unless $self->editor->create_action_hold_transit_copy($transit);
2773     } else {
2774         return $self->bail_on_events($self->editor->event)
2775             unless $self->editor->create_action_transit_copy($transit);
2776     }
2777
2778     # ensure the transit is returned to the caller
2779     $self->transit($transit);
2780
2781     $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2782     $self->update_copy;
2783     $self->checkin_changed(1);
2784 }
2785
2786
2787 sub hold_capture_is_possible {
2788     my $self = shift;
2789     my $copy = $self->copy;
2790
2791     # we've been explicitly told not to capture any holds
2792     return 0 if $self->capture eq 'nocapture';
2793
2794     # See if this copy can fulfill any holds
2795     my $hold = $holdcode->find_nearest_permitted_hold(
2796         $self->editor, $copy, $self->editor->requestor, 1 # check_only
2797     );
2798     return undef if ref $hold eq "HASH" and
2799         $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
2800     return $hold;
2801 }
2802
2803 sub reservation_capture_is_possible {
2804     my $self = shift;
2805     my $copy = $self->copy;
2806
2807     # we've been explicitly told not to capture any holds
2808     return 0 if $self->capture eq 'nocapture';
2809
2810     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2811     my $resv = $booking_ses->request(
2812         "open-ils.booking.reservations.could_capture",
2813         $self->editor->authtoken, $copy->barcode
2814     )->gather(1);
2815     $booking_ses->disconnect;
2816     if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
2817         $self->push_events($resv);
2818     } else {
2819         return $resv;
2820     }
2821 }
2822
2823 # returns true if the item was used (or may potentially be used 
2824 # in subsequent calls) to capture a hold.
2825 sub attempt_checkin_hold_capture {
2826     my $self = shift;
2827     my $copy = $self->copy;
2828
2829     # we've been explicitly told not to capture any holds
2830     return 0 if $self->capture eq 'nocapture';
2831
2832     # See if this copy can fulfill any holds
2833     my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold( 
2834         $self->editor, $copy, $self->editor->requestor );
2835
2836     if(!$hold) {
2837         $logger->debug("circulator: no potential permitted".
2838             "holds found for copy ".$copy->barcode);
2839         return 0;
2840     }
2841
2842     if($self->capture ne 'capture') {
2843         # see if this item is in a hold-capture-delay location
2844         my $location = $self->copy->location;
2845         if(!ref($location)) {
2846             $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2847             $self->copy->location($location);
2848         }
2849         if($U->is_true($location->hold_verify)) {
2850             $self->bail_on_events(
2851                 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
2852             return 1;
2853         }
2854     }
2855
2856     $self->retarget($retarget);
2857
2858     my $suppress_transit = 0;
2859     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2860         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2861         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2862             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2863             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2864                 $suppress_transit = 1;
2865                 $hold->pickup_lib($self->circ_lib);
2866             }
2867         }
2868     }
2869
2870     $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
2871
2872     $hold->current_copy($copy->id);
2873     $hold->capture_time('now');
2874     $self->put_hold_on_shelf($hold) 
2875         if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
2876
2877     # prevent DB errors caused by fetching 
2878     # holds from storage, and updating through cstore
2879     $hold->clear_fulfillment_time;
2880     $hold->clear_fulfillment_staff;
2881     $hold->clear_fulfillment_lib;
2882     $hold->clear_expire_time; 
2883     $hold->clear_cancel_time;
2884     $hold->clear_prev_check_time unless $hold->prev_check_time;
2885
2886     $self->bail_on_events($self->editor->event)
2887         unless $self->editor->update_action_hold_request($hold);
2888     $self->hold($hold);
2889     $self->checkin_changed(1);
2890
2891     return 0 if $self->bail_out;
2892
2893     if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
2894
2895         if ($hold->hold_type eq 'R') {
2896             $copy->status(OILS_COPY_STATUS_CATALOGING);
2897             $hold->fulfillment_time('now');
2898             $self->noop(1); # Block other transit/hold checks
2899             $self->bail_on_events($self->editor->event)
2900                 unless $self->editor->update_action_hold_request($hold);
2901         } else {
2902             # This hold was captured in the correct location
2903             $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2904             $self->push_events(OpenILS::Event->new('SUCCESS'));
2905
2906             #$self->do_hold_notify($hold->id);
2907             $self->notify_hold($hold->id);
2908         }
2909
2910     } else {
2911     
2912         # Hold needs to be picked up elsewhere.  Build a hold
2913         # transit and route the item.
2914         $self->checkin_build_hold_transit();
2915         $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2916         return 0 if $self->bail_out;
2917         $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
2918     }
2919
2920     # make sure we save the copy status
2921     $self->update_copy;
2922     return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
2923     return 1;
2924 }
2925
2926 sub attempt_checkin_reservation_capture {
2927     my $self = shift;
2928     my $copy = $self->copy;
2929
2930     # we've been explicitly told not to capture any holds
2931     return 0 if $self->capture eq 'nocapture';
2932
2933     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2934     my $evt = $booking_ses->request(
2935         "open-ils.booking.resources.capture_for_reservation",
2936         $self->editor->authtoken,
2937         $copy->barcode,
2938         1 # don't update copy - we probably have it locked
2939     )->gather(1);
2940     $booking_ses->disconnect;
2941
2942     if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
2943         $logger->warn(
2944             "open-ils.booking.resources.capture_for_reservation " .
2945             "didn't return an event!"
2946         );
2947     } else {
2948         if (
2949             $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
2950             $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
2951         ) {
2952             # not-transferable is an error event we'll pass on the user
2953             $logger->warn("reservation capture attempted against non-transferable item");
2954             $self->push_events($evt);
2955             return 0;
2956         } elsif ($evt->{"textcode"} eq "SUCCESS") {
2957             # Re-retrieve copy as reservation capture may have changed
2958             # its status and whatnot.
2959             $logger->info(
2960                 "circulator: booking capture win on copy " . $self->copy->id
2961             );
2962             if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
2963                 $logger->info(
2964                     "circulator: changing copy " . $self->copy->id .
2965                     "'s status from " . $self->copy->status . " to " .
2966                     $new_copy_status
2967                 );
2968                 $self->copy->status($new_copy_status);
2969                 $self->update_copy;
2970             }
2971             $self->reservation($evt->{"payload"}->{"reservation"});
2972
2973             if (exists $evt->{"payload"}->{"transit"}) {
2974                 $self->push_events(
2975                     new OpenILS::Event(
2976                         "ROUTE_ITEM",
2977                         "org" => $evt->{"payload"}->{"transit"}->dest
2978                     )
2979                 );
2980             }
2981             $self->checkin_changed(1);
2982             return 1;
2983         }
2984     }
2985     # other results are treated as "nothing to capture"
2986     return 0;
2987 }
2988
2989 sub do_hold_notify {
2990     my( $self, $holdid ) = @_;
2991
2992     my $e = new_editor(xact => 1);
2993     my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
2994     $e->rollback;
2995     my $ses = OpenSRF::AppSession->create('open-ils.trigger');
2996     $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
2997
2998     $logger->info("circulator: running delayed hold notify process");
2999
3000 #   my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3001 #       hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3002
3003     my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3004         hold_id => $holdid, requestor => $self->editor->requestor);
3005
3006     $logger->debug("circulator: built hold notifier");
3007
3008     if(!$notifier->event) {
3009
3010         $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3011
3012         my $stat = $notifier->send_email_notify;
3013         if( $stat == '1' ) {
3014             $logger->info("circulator: hold notify succeeded for hold $holdid");
3015             return;
3016         } 
3017
3018         $logger->debug("circulator:  * hold notify cancelled or failed for hold $holdid");
3019
3020     } else {
3021         $logger->info("circulator: Not sending hold notification since the patron has no email address");
3022     }
3023 }
3024
3025 sub retarget_holds {
3026     my $self = shift;
3027     $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3028     my $ses = OpenSRF::AppSession->create('open-ils.storage');
3029     $ses->request('open-ils.storage.action.hold_request.copy_targeter', undef, $self->retarget);
3030     # no reason to wait for the return value
3031     return;
3032 }
3033
3034 sub checkin_build_hold_transit {
3035     my $self = shift;
3036
3037    my $copy = $self->copy;
3038    my $hold = $self->hold;
3039    my $trans = Fieldmapper::action::hold_transit_copy->new;
3040
3041     $logger->debug("circulator: building hold transit for ".$copy->barcode);
3042
3043    $trans->hold($hold->id);
3044    $trans->source($self->circ_lib);
3045    $trans->dest($hold->pickup_lib);
3046    $trans->source_send_time("now");
3047    $trans->target_copy($copy->id);
3048
3049     # when the copy gets to its destination, it will recover
3050     # this status - put it onto the holds shelf
3051    $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3052
3053     return $self->bail_on_events($self->editor->event)
3054         unless $self->editor->create_action_hold_transit_copy($trans);
3055 }
3056
3057
3058
3059 sub process_received_transit {
3060     my $self = shift;
3061     my $copy = $self->copy;
3062     my $copyid = $self->copy->id;
3063
3064     my $status_name = $U->copy_status($copy->status)->name;
3065     $logger->debug("circulator: attempting transit receive on ".
3066         "copy $copyid. Copy status is $status_name");
3067
3068     my $transit = $self->transit;
3069
3070     # Check if we are in a&nb