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