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