Allow more granular overrides
[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, $oargs ) = @_;
195     translate_legacy_args($args);
196     $oargs = { all => 1 } unless defined $oargs;
197     my $api = $self->api_name;
198
199     my $circulator = 
200         OpenILS::Application::Circ::Circulator->new($auth, %$args, $oargs);
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 /;
546
547
548 sub AUTOLOAD {
549     my $self = shift;
550     my $type = ref($self) or die "$self is not an object";
551     my $data = shift;
552     my $name = $AUTOLOAD;
553     $name =~ s/.*://o;   
554
555     unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
556         $logger->error("circulator: $type: invalid autoload field: $name");
557         die "$type: invalid autoload field: $name\n" 
558     }
559
560     {
561         no strict 'refs';
562         *{"${type}::${name}"} = sub {
563             my $s = shift;
564             my $v = shift;
565             $s->{$name} = $v if defined $v;
566             return $s->{$name};
567         }
568     }
569     return $self->$name($data);
570 }
571
572
573 sub new {
574     my( $class, $auth, %args, $oargs ) = @_;
575     $class = ref($class) || $class;
576     my $self = bless( {}, $class );
577
578     $self->events([]);
579     $self->editor(new_editor(xact => 1, authtoken => $auth));
580     $self->override_args($oargs);
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 $holds   = $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     );
1004
1005     if( $holds and $$holds[0] ) {
1006         return undef if $$holds[0]->usr == $patron->id;
1007     }
1008
1009     $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1010
1011     $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF'));
1012 }
1013
1014
1015 sub do_copy_checks {
1016     my $self = shift;
1017     my $copy = $self->copy;
1018     return unless $copy;
1019
1020     my $stat = $U->copy_status($copy->status)->id;
1021
1022     # We cannot check out a copy if it is in-transit
1023     if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1024         return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1025     }
1026
1027     $self->handle_claims_returned();
1028     return if $self->bail_out;
1029
1030     # no claims returned circ was found, check if there is any open circ
1031     unless( $self->is_renewal ) {
1032
1033         my $circs = $self->editor->search_action_circulation(
1034             { target_copy => $copy->id, checkin_time => undef }
1035         );
1036
1037         if(my $old_circ = $circs->[0]) { # an open circ was found
1038
1039             my $payload = {copy => $copy};
1040
1041             if($old_circ->usr == $self->patron->id) {
1042                 
1043                 $payload->{old_circ} = $old_circ;
1044
1045                 # If there is an open circulation on the checkout item and an auto-renew 
1046                 # interval is defined, inform the caller that they should go 
1047                 # ahead and renew the item instead of warning about open circulations.
1048     
1049                 my $auto_renew_intvl = $U->ou_ancestor_setting_value(        
1050                     $self->circ_lib,
1051                     'circ.checkout_auto_renew_age', 
1052                     $self->editor
1053                 );
1054
1055                 if($auto_renew_intvl) {
1056                     my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
1057                     my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
1058
1059                     if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1060                         $payload->{auto_renew} = 1;
1061                     }
1062                 }
1063             }
1064
1065             return $self->bail_on_events(
1066                 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1067             );
1068         }
1069     }
1070 }
1071
1072 my $LEGACY_CIRC_EVENT_MAP = {
1073     'no_item' => 'ITEM_NOT_CATALOGED',
1074     'actor.usr.barred' => 'PATRON_BARRED',
1075     'asset.copy.circulate' =>  'COPY_CIRC_NOT_ALLOWED',
1076     'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1077     'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1078     'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1079     'config.circ_matrix_test.max_items_out' =>  'PATRON_EXCEEDS_CHECKOUT_COUNT',
1080     'config.circ_matrix_test.max_overdue' =>  'PATRON_EXCEEDS_OVERDUE_COUNT',
1081     'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1082     'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1083 };
1084
1085
1086 # ---------------------------------------------------------------------
1087 # This pushes any patron-related events into the list but does not
1088 # set bail_out for any events
1089 # ---------------------------------------------------------------------
1090 sub run_patron_permit_scripts {
1091     my $self        = shift;
1092     my $runner      = $self->script_runner;
1093     my $patronid    = $self->patron->id;
1094
1095     my @allevents; 
1096
1097     if(!$self->legacy_script_support) {
1098
1099         my $results = $self->run_indb_circ_test;
1100         unless($self->circ_test_success) {
1101             # no_item result is OK during noncat checkout
1102             unless(@$results == 1 && $results->[0]->{fail_part} eq 'no_item' and $self->is_noncat) {
1103                 push @allevents, $self->matrix_test_result_events;
1104             }
1105         }
1106
1107     } else {
1108
1109         # --------------------------------------------------------------------- 
1110         # # Now run the patron permit script 
1111         # ---------------------------------------------------------------------
1112         $runner->load($self->circ_permit_patron);
1113         my $result = $runner->run or 
1114             throw OpenSRF::EX::ERROR ("Circ Permit Patron Script Died: $@");
1115
1116         my $patron_events = $result->{events};
1117
1118         OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1119         my $mask = ($self->is_renewal) ? 'RENEW' : 'CIRC';
1120         my $penalties = OpenILS::Utils::Penalty->retrieve_penalties($self->editor, $patronid, $self->circ_lib, $mask);
1121         $penalties = $penalties->{fatal_penalties};
1122
1123         for my $pen (@$penalties) {
1124             my $event = OpenILS::Event->new($pen->name);
1125             $event->{desc} = $pen->label;
1126             push(@allevents, $event);
1127         }
1128
1129         push(@allevents, OpenILS::Event->new($_)) for (@$patron_events);
1130     }
1131
1132     for (@allevents) {
1133        $_->{payload} = $self->copy if 
1134              ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1135     }
1136
1137     $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1138
1139     $self->push_events(@allevents);
1140 }
1141
1142 sub matrix_test_result_codes {
1143     my $self = shift;
1144     map { $_->{"fail_part"} } @{$self->matrix_test_result};
1145 }
1146
1147 sub matrix_test_result_events {
1148     my $self = shift;
1149     map {
1150         my $event = new OpenILS::Event(
1151             $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1152         );
1153         $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1154         $event;
1155     } (@{$self->matrix_test_result});
1156 }
1157
1158 sub run_indb_circ_test {
1159     my $self = shift;
1160     return $self->matrix_test_result if $self->matrix_test_result;
1161
1162     my $dbfunc = ($self->is_renewal) ? 
1163         'action.item_user_renew_test' : 'action.item_user_circ_test';
1164
1165     if( $self->is_precat && $self->request_precat) {
1166         $self->make_precat_copy;
1167         return if $self->bail_out;
1168     }
1169
1170     my $results = $self->editor->json_query(
1171         {   from => [
1172                 $dbfunc,
1173                 $self->circ_lib,
1174                 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id, 
1175                 $self->patron->id,
1176             ]
1177         }
1178     );
1179
1180     $self->circ_test_success($U->is_true($results->[0]->{success}));
1181
1182     if(my $mp = $results->[0]->{matchpoint}) {
1183         $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1184         $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1185         $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1186         if(defined($results->[0]->{renewals})) {
1187             $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1188         }
1189         $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1190         if(defined($results->[0]->{grace_period})) {
1191             $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1192         }
1193         $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1194         $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1195         # Grab the *last* response for limit_groups, where it is more likely to be filled
1196         $self->limit_groups($results->[-1]->{limit_groups});
1197     }
1198
1199     return $self->matrix_test_result($results);
1200 }
1201
1202 # ---------------------------------------------------------------------
1203 # given a use and copy, this will calculate the circulation policy
1204 # parameters.  Only works with in-db circ.
1205 # ---------------------------------------------------------------------
1206 sub do_inspect {
1207     my $self = shift;
1208
1209     return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1210
1211     $self->run_indb_circ_test;
1212
1213     my $results = {
1214         circ_test_success => $self->circ_test_success,
1215         failure_events => [],
1216         failure_codes => [],
1217         matchpoint => $self->circ_matrix_matchpoint
1218     };
1219
1220     unless($self->circ_test_success) {
1221         $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1222         $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1223     }
1224
1225     if($self->circ_matrix_matchpoint) {
1226         my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1227         my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1228         my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1229         my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1230     
1231         my $policy = $self->get_circ_policy(
1232             $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1233     
1234         $$results{$_} = $$policy{$_} for keys %$policy;
1235     }
1236
1237     return $results;
1238 }
1239
1240 # ---------------------------------------------------------------------
1241 # Loads the circ policy info for duration, recurring fine, and max
1242 # fine based on the current copy
1243 # ---------------------------------------------------------------------
1244 sub get_circ_policy {
1245     my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1246
1247     my $policy = {
1248         duration_rule => $duration_rule->name,
1249         recurring_fine_rule => $recurring_fine_rule->name,
1250         max_fine_rule => $max_fine_rule->name,
1251         max_fine => $self->get_max_fine_amount($max_fine_rule),
1252         fine_interval => $recurring_fine_rule->recurrence_interval,
1253         renewal_remaining => $duration_rule->max_renewals,
1254         grace_period => $recurring_fine_rule->grace_period
1255     };
1256
1257     if($hard_due_date) {
1258         $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1259         $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1260     }
1261     else {
1262         $policy->{duration_date_ceiling} = undef;
1263         $policy->{duration_date_ceiling_force} = undef;
1264     }
1265
1266     $policy->{duration} = $duration_rule->shrt
1267         if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1268     $policy->{duration} = $duration_rule->normal
1269         if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1270     $policy->{duration} = $duration_rule->extended
1271         if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1272
1273     $policy->{recurring_fine} = $recurring_fine_rule->low
1274         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1275     $policy->{recurring_fine} = $recurring_fine_rule->normal
1276         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1277     $policy->{recurring_fine} = $recurring_fine_rule->high
1278         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1279
1280     return $policy;
1281 }
1282
1283 sub get_max_fine_amount {
1284     my $self = shift;
1285     my $max_fine_rule = shift;
1286     my $max_amount = $max_fine_rule->amount;
1287
1288     # if is_percent is true then the max->amount is
1289     # use as a percentage of the copy price
1290     if ($U->is_true($max_fine_rule->is_percent)) {
1291         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1292         $max_amount = $price * $max_fine_rule->amount / 100;
1293     } elsif (
1294         $U->ou_ancestor_setting_value(
1295             $self->circ_lib,
1296             'circ.max_fine.cap_at_price',
1297             $self->editor
1298         )
1299     ) {
1300         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1301         $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1302     }
1303
1304     return $max_amount;
1305 }
1306
1307
1308
1309 sub run_copy_permit_scripts {
1310     my $self = shift;
1311     my $copy = $self->copy || return;
1312     my $runner = $self->script_runner;
1313
1314     my @allevents;
1315
1316     if(!$self->legacy_script_support) {
1317         my $results = $self->run_indb_circ_test;
1318         push @allevents, $self->matrix_test_result_events
1319             unless $self->circ_test_success;
1320     } else {
1321     
1322        # ---------------------------------------------------------------------
1323        # Capture all of the copy permit events
1324        # ---------------------------------------------------------------------
1325        $runner->load($self->circ_permit_copy);
1326        my $result = $runner->run or 
1327             throw OpenSRF::EX::ERROR ("Circ Permit Copy Script Died: $@");
1328        my $copy_events = $result->{events};
1329
1330        # ---------------------------------------------------------------------
1331        # Now collect all of the events together
1332        # ---------------------------------------------------------------------
1333        push( @allevents, OpenILS::Event->new($_)) for @$copy_events;
1334     }
1335
1336     # See if this copy has an alert message
1337     my $ae = $self->check_copy_alert();
1338     push( @allevents, $ae ) if $ae;
1339
1340    # uniquify the events
1341    my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1342    @allevents = values %hash;
1343
1344     $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1345
1346     $self->push_events(@allevents);
1347 }
1348
1349
1350 sub check_copy_alert {
1351     my $self = shift;
1352     return undef if $self->is_renewal;
1353     return OpenILS::Event->new(
1354         'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1355         if $self->copy and $self->copy->alert_message;
1356     return undef;
1357 }
1358
1359
1360
1361 # --------------------------------------------------------------------------
1362 # If the call is overriding and has permissions to override every collected
1363 # event, the are cleared.  Any event that the caller does not have
1364 # permission to override, will be left in the event list and bail_out will
1365 # be set
1366 # XXX We need code in here to cancel any holds/transits on copies 
1367 # that are being force-checked out
1368 # --------------------------------------------------------------------------
1369 sub override_events {
1370     my $self = shift;
1371     my @events = @{$self->events};
1372     return unless @events;
1373     my $oargs = $self->override_args;
1374
1375     if(!$self->override) {
1376         return $self->bail_out(1) 
1377             if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1378     }   
1379
1380     $self->events([]);
1381     
1382     for my $e (@events) {
1383         my $tc = $e->{textcode};
1384         next if $tc eq 'SUCCESS';
1385         if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1386             my $ov = "$tc.override";
1387             $logger->info("circulator: attempting to override event: $ov");
1388
1389             return $self->bail_on_events($self->editor->event)
1390                 unless( $self->editor->allowed($ov) );
1391         } else {
1392             return $self->bail_out(1);
1393         }
1394    }
1395 }
1396     
1397
1398 # --------------------------------------------------------------------------
1399 # If there is an open claimsreturn circ on the requested copy, close the 
1400 # circ if overriding, otherwise bail out
1401 # --------------------------------------------------------------------------
1402 sub handle_claims_returned {
1403     my $self = shift;
1404     my $copy = $self->copy;
1405
1406     my $CR = $self->editor->search_action_circulation(
1407         {   
1408             target_copy     => $copy->id,
1409             stop_fines      => OILS_STOP_FINES_CLAIMSRETURNED,
1410             checkin_time    => undef,
1411         }
1412     );
1413
1414     return unless ($CR = $CR->[0]); 
1415
1416     my $evt;
1417
1418     # - If the caller has set the override flag, we will check the item in
1419     if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1420
1421         $CR->checkin_time('now');   
1422         $CR->checkin_scan_time('now');   
1423         $CR->checkin_lib($self->circ_lib);
1424         $CR->checkin_workstation($self->editor->requestor->wsid);
1425         $CR->checkin_staff($self->editor->requestor->id);
1426
1427         $evt = $self->editor->event 
1428             unless $self->editor->update_action_circulation($CR);
1429
1430     } else {
1431         $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1432     }
1433
1434     $self->bail_on_events($evt) if $evt;
1435     return;
1436 }
1437
1438
1439 # --------------------------------------------------------------------------
1440 # This performs the checkout
1441 # --------------------------------------------------------------------------
1442 sub do_checkout {
1443     my $self = shift;
1444
1445     $self->log_me("do_checkout()");
1446
1447     # make sure perms are good if this isn't a renewal
1448     unless( $self->is_renewal ) {
1449         return $self->bail_on_events($self->editor->event)
1450             unless( $self->editor->allowed('COPY_CHECKOUT') );
1451     }
1452
1453     # verify the permit key
1454     unless( $self->check_permit_key ) {
1455         if( $self->permit_override ) {
1456             return $self->bail_on_events($self->editor->event)
1457                 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1458         } else {
1459             return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1460         }   
1461     }
1462
1463     # if this is a non-cataloged circ, build the circ and finish
1464     if( $self->is_noncat ) {
1465         $self->checkout_noncat;
1466         $self->push_events(
1467             OpenILS::Event->new('SUCCESS', 
1468             payload => { noncat_circ => $self->circ }));
1469         return;
1470     }
1471
1472     if( $self->is_precat ) {
1473         $self->make_precat_copy;
1474         return if $self->bail_out;
1475
1476     } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1477         return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1478     }
1479
1480     $self->do_copy_checks;
1481     return if $self->bail_out;
1482
1483     $self->run_checkout_scripts();
1484     return if $self->bail_out;
1485
1486     $self->build_checkout_circ_object();
1487     return if $self->bail_out;
1488
1489     my $modify_to_start = $self->booking_adjusted_due_date();
1490     return if $self->bail_out;
1491
1492     $self->apply_modified_due_date($modify_to_start);
1493     return if $self->bail_out;
1494
1495     return $self->bail_on_events($self->editor->event)
1496         unless $self->editor->create_action_circulation($self->circ);
1497
1498     # refresh the circ to force local time zone for now
1499     $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1500
1501     if($self->limit_groups) {
1502         $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1503     }
1504
1505     $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1506     $self->update_copy;
1507     return if $self->bail_out;
1508
1509     $self->apply_deposit_fee();
1510     return if $self->bail_out;
1511
1512     $self->handle_checkout_holds();
1513     return if $self->bail_out;
1514
1515     # ------------------------------------------------------------------------------
1516     # Update the patron penalty info in the DB.  Run it for permit-overrides 
1517     # since the penalties are not updated during the permit phase
1518     # ------------------------------------------------------------------------------
1519     OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1520
1521     my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1522     
1523     my $pcirc;
1524     if($self->is_renewal) {
1525         # flesh the billing summary for the checked-in circ
1526         $pcirc = $self->editor->retrieve_action_circulation([
1527             $self->parent_circ,
1528             {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1529         ]);
1530     }
1531
1532     $self->push_events(
1533         OpenILS::Event->new('SUCCESS',
1534             payload  => {
1535                 copy             => $U->unflesh_copy($self->copy),
1536                 volume           => $self->volume,
1537                 circ             => $self->circ,
1538                 record           => $record,
1539                 holds_fulfilled  => $self->fulfilled_holds,
1540                 deposit_billing  => $self->deposit_billing,
1541                 rental_billing   => $self->rental_billing,
1542                 parent_circ      => $pcirc,
1543                 patron           => ($self->return_patron) ? $self->patron : undef,
1544                 patron_money     => $self->editor->retrieve_money_user_summary($self->patron->id)
1545             }
1546         )
1547     );
1548 }
1549
1550 sub apply_deposit_fee {
1551     my $self = shift;
1552     my $copy = $self->copy;
1553     return unless 
1554         ($self->is_deposit and not $self->is_deposit_exempt) or 
1555         ($self->is_rental and not $self->is_rental_exempt);
1556
1557     return if $self->is_deposit and $self->skip_deposit_fee;
1558     return if $self->is_rental and $self->skip_rental_fee;
1559
1560         my $bill = Fieldmapper::money::billing->new;
1561     my $amount = $copy->deposit_amount;
1562     my $billing_type;
1563     my $btype;
1564
1565     if($self->is_deposit) {
1566         $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1567         $btype = 5;
1568         $self->deposit_billing($bill);
1569     } else {
1570         $billing_type = OILS_BILLING_TYPE_RENTAL;
1571         $btype = 6;
1572         $self->rental_billing($bill);
1573     }
1574
1575         $bill->xact($self->circ->id);
1576         $bill->amount($amount);
1577         $bill->note(OILS_BILLING_NOTE_SYSTEM);
1578         $bill->billing_type($billing_type);
1579         $bill->btype($btype);
1580     $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1581
1582         $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1583 }
1584
1585 sub update_copy {
1586     my $self = shift;
1587     my $copy = $self->copy;
1588
1589     my $stat = $copy->status if ref $copy->status;
1590     my $loc = $copy->location if ref $copy->location;
1591     my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1592
1593     $copy->status($stat->id) if $stat;
1594     $copy->location($loc->id) if $loc;
1595     $copy->circ_lib($circ_lib->id) if $circ_lib;
1596     $copy->editor($self->editor->requestor->id);
1597     $copy->edit_date('now');
1598     $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1599
1600     return $self->bail_on_events($self->editor->event)
1601         unless $self->editor->update_asset_copy($self->copy);
1602
1603     $copy->status($U->copy_status($copy->status));
1604     $copy->location($loc) if $loc;
1605     $copy->circ_lib($circ_lib) if $circ_lib;
1606 }
1607
1608 sub update_reservation {
1609     my $self = shift;
1610     my $reservation = $self->reservation;
1611
1612     my $usr = $reservation->usr;
1613     my $target_rt = $reservation->target_resource_type;
1614     my $target_r = $reservation->target_resource;
1615     my $current_r = $reservation->current_resource;
1616
1617     $reservation->usr($usr->id) if ref $usr;
1618     $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1619     $reservation->target_resource($target_r->id) if ref $target_r;
1620     $reservation->current_resource($current_r->id) if ref $current_r;
1621
1622     return $self->bail_on_events($self->editor->event)
1623         unless $self->editor->update_booking_reservation($self->reservation);
1624
1625     my $evt;
1626     ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1627     $self->reservation($reservation);
1628 }
1629
1630
1631 sub bail_on_events {
1632     my( $self, @evts ) = @_;
1633     $self->push_events(@evts);
1634     $self->bail_out(1);
1635 }
1636
1637
1638 # ------------------------------------------------------------------------------
1639 # When an item is checked out, see if we can fulfill a hold for this patron
1640 # ------------------------------------------------------------------------------
1641 sub handle_checkout_holds {
1642    my $self    = shift;
1643    my $copy    = $self->copy;
1644    my $patron  = $self->patron;
1645
1646    my $e = $self->editor;
1647    $self->fulfilled_holds([]);
1648
1649    # pre/non-cats can't fulfill a hold
1650    return if $self->is_precat or $self->is_noncat;
1651
1652     my $hold = $e->search_action_hold_request({   
1653         current_copy        => $copy->id , 
1654         cancel_time         => undef, 
1655         fulfillment_time    => undef,
1656         '-or' => [
1657             {expire_time => undef},
1658             {expire_time => {'>' => 'now'}}
1659         ]
1660     })->[0];
1661
1662     if($hold and $hold->usr != $patron->id) {
1663         # reset the hold since the copy is now checked out
1664     
1665         $logger->info("circulator: un-targeting hold ".$hold->id.
1666             " because copy ".$copy->id." is getting checked out");
1667
1668         $hold->clear_prev_check_time; 
1669         $hold->clear_current_copy;
1670         $hold->clear_capture_time;
1671         $hold->clear_shelf_time;
1672         $hold->clear_shelf_expire_time;
1673             $hold->clear_current_shelf_lib;
1674
1675         return $self->bail_on_event($e->event)
1676             unless $e->update_action_hold_request($hold);
1677
1678         $hold = undef;
1679     }
1680
1681     unless($hold) {
1682         $hold = $self->find_related_user_hold($copy, $patron) or return;
1683         $logger->info("circulator: found related hold to fulfill in checkout");
1684     }
1685
1686     $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1687
1688     # if the hold was never officially captured, capture it.
1689     $hold->current_copy($copy->id);
1690     $hold->capture_time('now') unless $hold->capture_time;
1691     $hold->fulfillment_time('now');
1692     $hold->fulfillment_staff($e->requestor->id);
1693     $hold->fulfillment_lib($self->circ_lib);
1694
1695     return $self->bail_on_events($e->event)
1696         unless $e->update_action_hold_request($hold);
1697
1698     $holdcode->delete_hold_copy_maps($e, $hold->id);
1699     return $self->fulfilled_holds([$hold->id]);
1700 }
1701
1702
1703 # ------------------------------------------------------------------------------
1704 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1705 # the patron directly targets the checked out item, see if there is another hold 
1706 # for the patron that could be fulfilled by the checked out item.  Fulfill the
1707 # oldest hold and only fulfill 1 of them.
1708
1709 # For "another hold":
1710 #
1711 # First, check for one that the copy matches via hold_copy_map, ensuring that
1712 # *any* hold type that this copy could fill may end up filled.
1713 #
1714 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1715 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1716 # that are non-requestable to count as capturing those hold types.
1717 # ------------------------------------------------------------------------------
1718 sub find_related_user_hold {
1719     my($self, $copy, $patron) = @_;
1720     my $e = $self->editor;
1721
1722     return undef if $self->volume->id == OILS_PRECAT_CALL_NUMBER; 
1723
1724     return undef unless $U->ou_ancestor_setting_value(        
1725         $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1726
1727     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1728     my $args = {
1729         select => {ahr => ['id']}, 
1730         from => {
1731             ahr => {
1732                 ahcm => {
1733                     field => 'hold',
1734                     fkey => 'id'
1735                 },
1736                 acp => {
1737                     field => 'id', 
1738                     fkey => 'current_copy',
1739                     type => 'left' # there may be no current_copy
1740                 }
1741             }
1742         }, 
1743         where => {
1744             '+ahr' => {
1745                 usr => $patron->id,
1746                 fulfillment_time => undef,
1747                 cancel_time => undef,
1748                '-or' => [
1749                     {expire_time => undef},
1750                     {expire_time => {'>' => 'now'}}
1751                 ]
1752             },
1753             '+ahcm' => {
1754                 target_copy => $self->copy->id
1755             },
1756             '+acp' => {
1757                 '-or' => [
1758                     {id => undef}, # left-join copy may be nonexistent
1759                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1760                 ]
1761             }
1762         },
1763         order_by => {ahr => {request_time => {direction => 'asc'}}},
1764         limit => 1
1765     };
1766
1767     my $hold_info = $e->json_query($args)->[0];
1768     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1769     return undef if $U->ou_ancestor_setting_value(        
1770         $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1771
1772     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1773     $args = {
1774         select => {ahr => ['id']}, 
1775         from => {
1776             ahr => {
1777                 acp => {
1778                     field => 'id', 
1779                     fkey => 'current_copy',
1780                     type => 'left' # there may be no current_copy
1781                 }
1782             }
1783         }, 
1784         where => {
1785             '+ahr' => {
1786                 usr => $patron->id,
1787                 fulfillment_time => undef,
1788                 cancel_time => undef,
1789                '-or' => [
1790                     {expire_time => undef},
1791                     {expire_time => {'>' => 'now'}}
1792                 ]
1793             },
1794             '-or' => [
1795                 {
1796                     '+ahr' => { 
1797                         hold_type => 'V',
1798                         target => $self->volume->id
1799                     }
1800                 },
1801                 { 
1802                     '+ahr' => { 
1803                         hold_type => 'T',
1804                         target => $self->title->id
1805                     }
1806                 },
1807             ],
1808             '+acp' => {
1809                 '-or' => [
1810                     {id => undef}, # left-join copy may be nonexistent
1811                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1812                 ]
1813             }
1814         },
1815         order_by => {ahr => {request_time => {direction => 'asc'}}},
1816         limit => 1
1817     };
1818
1819     $hold_info = $e->json_query($args)->[0];
1820     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1821     return undef;
1822 }
1823
1824
1825 sub run_checkout_scripts {
1826     my $self = shift;
1827     my $nobail = shift;
1828
1829     my $evt;
1830     my $runner = $self->script_runner;
1831
1832     my $duration;
1833     my $recurring;
1834     my $max_fine;
1835     my $hard_due_date;
1836     my $duration_name;
1837     my $recurring_name;
1838     my $max_fine_name;
1839     my $hard_due_date_name;
1840
1841     if(!$self->legacy_script_support) {
1842         $self->run_indb_circ_test();
1843         $duration = $self->circ_matrix_matchpoint->duration_rule;
1844         $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
1845         $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
1846         $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1847
1848     } else {
1849
1850        $runner->load($self->circ_duration);
1851
1852        my $result = $runner->run or 
1853             throw OpenSRF::EX::ERROR ("Circ Duration Script Died: $@");
1854
1855        $duration_name   = $result->{durationRule};
1856        $recurring_name  = $result->{recurringFinesRule};
1857        $max_fine_name   = $result->{maxFine};
1858        $hard_due_date_name  = $result->{hardDueDate};
1859     }
1860
1861     $duration_name = $duration->name if $duration;
1862     if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
1863
1864         unless($duration) {
1865             ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
1866             return $self->bail_on_events($evt) if ($evt && !$nobail);
1867         
1868             ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
1869             return $self->bail_on_events($evt) if ($evt && !$nobail);
1870         
1871             ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
1872             return $self->bail_on_events($evt) if ($evt && !$nobail);
1873
1874             if($hard_due_date_name) {
1875                 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
1876                 return $self->bail_on_events($evt) if ($evt && !$nobail);
1877             }
1878         }
1879
1880     } else {
1881
1882         # The item circulates with an unlimited duration
1883         $duration   = undef;
1884         $recurring  = undef;
1885         $max_fine   = undef;
1886         $hard_due_date = undef;
1887     }
1888
1889    $self->duration_rule($duration);
1890    $self->recurring_fines_rule($recurring);
1891    $self->max_fine_rule($max_fine);
1892    $self->hard_due_date($hard_due_date);
1893 }
1894
1895
1896 sub build_checkout_circ_object {
1897     my $self = shift;
1898
1899    my $circ       = Fieldmapper::action::circulation->new;
1900    my $duration   = $self->duration_rule;
1901    my $max        = $self->max_fine_rule;
1902    my $recurring  = $self->recurring_fines_rule;
1903    my $hard_due_date    = $self->hard_due_date;
1904    my $copy       = $self->copy;
1905    my $patron     = $self->patron;
1906    my $duration_date_ceiling;
1907    my $duration_date_ceiling_force;
1908
1909     if( $duration ) {
1910
1911         my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
1912         $duration_date_ceiling = $policy->{duration_date_ceiling};
1913         $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
1914
1915         my $dname = $duration->name;
1916         my $mname = $max->name;
1917         my $rname = $recurring->name;
1918         my $hdname = ''; 
1919         if($hard_due_date) {
1920             $hdname = $hard_due_date->name;
1921         }
1922
1923         $logger->debug("circulator: building circulation ".
1924             "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
1925     
1926         $circ->duration($policy->{duration});
1927         $circ->recurring_fine($policy->{recurring_fine});
1928         $circ->duration_rule($duration->name);
1929         $circ->recurring_fine_rule($recurring->name);
1930         $circ->max_fine_rule($max->name);
1931         $circ->max_fine($policy->{max_fine});
1932         $circ->fine_interval($recurring->recurrence_interval);
1933         $circ->renewal_remaining($duration->max_renewals);
1934         $circ->grace_period($policy->{grace_period});
1935
1936     } else {
1937
1938         $logger->info("circulator: copy found with an unlimited circ duration");
1939         $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
1940         $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1941         $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1942         $circ->renewal_remaining(0);
1943         $circ->grace_period(0);
1944     }
1945
1946    $circ->target_copy( $copy->id );
1947    $circ->usr( $patron->id );
1948    $circ->circ_lib( $self->circ_lib );
1949    $circ->workstation($self->editor->requestor->wsid) 
1950     if defined $self->editor->requestor->wsid;
1951
1952     # renewals maintain a link to the parent circulation
1953     $circ->parent_circ($self->parent_circ);
1954
1955    if( $self->is_renewal ) {
1956       $circ->opac_renewal('t') if $self->opac_renewal;
1957       $circ->phone_renewal('t') if $self->phone_renewal;
1958       $circ->desk_renewal('t') if $self->desk_renewal;
1959       $circ->renewal_remaining($self->renewal_remaining);
1960       $circ->circ_staff($self->editor->requestor->id);
1961    }
1962
1963
1964     # if the user provided an overiding checkout time,
1965     # (e.g. the checkout really happened several hours ago), then
1966     # we apply that here.  Does this need a perm??
1967     $circ->xact_start(cleanse_ISO8601($self->checkout_time))
1968         if $self->checkout_time;
1969
1970     # if a patron is renewing, 'requestor' will be the patron
1971     $circ->circ_staff($self->editor->requestor->id);
1972     $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force) ) if $circ->duration;
1973
1974     $self->circ($circ);
1975 }
1976
1977 sub do_reservation_pickup {
1978     my $self = shift;
1979
1980     $self->log_me("do_reservation_pickup()");
1981
1982     $self->reservation->pickup_time('now');
1983
1984     if (
1985         $self->reservation->current_resource &&
1986         $U->is_true($self->reservation->target_resource_type->catalog_item)
1987     ) {
1988         # We used to try to set $self->copy and $self->patron here,
1989         # but that should already be done.
1990
1991         $self->run_checkout_scripts(1);
1992
1993         my $duration   = $self->duration_rule;
1994         my $max        = $self->max_fine_rule;
1995         my $recurring  = $self->recurring_fines_rule;
1996
1997         if ($duration && $max && $recurring) {
1998             my $policy = $self->get_circ_policy($duration, $recurring, $max);
1999
2000             my $dname = $duration->name;
2001             my $mname = $max->name;
2002             my $rname = $recurring->name;
2003
2004             $logger->debug("circulator: updating reservation ".
2005                 "with duration=$dname, maxfine=$mname, recurring=$rname");
2006
2007             $self->reservation->fine_amount($policy->{recurring_fine});
2008             $self->reservation->max_fine($policy->{max_fine});
2009             $self->reservation->fine_interval($recurring->recurrence_interval);
2010         }
2011
2012         $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2013         $self->update_copy();
2014
2015     } else {
2016         $self->reservation->fine_amount(
2017             $self->reservation->target_resource_type->fine_amount
2018         );
2019         $self->reservation->max_fine(
2020             $self->reservation->target_resource_type->max_fine
2021         );
2022         $self->reservation->fine_interval(
2023             $self->reservation->target_resource_type->fine_interval
2024         );
2025     }
2026
2027     $self->update_reservation();
2028 }
2029
2030 sub do_reservation_return {
2031     my $self = shift;
2032     my $request = shift;
2033
2034     $self->log_me("do_reservation_return()");
2035
2036     if (not ref $self->reservation) {
2037         my ($reservation, $evt) =
2038             $U->fetch_booking_reservation($self->reservation);
2039         return $self->bail_on_events($evt) if $evt;
2040         $self->reservation($reservation);
2041     }
2042
2043     $self->generate_fines(1);
2044     $self->reservation->return_time('now');
2045     $self->update_reservation();
2046     $self->reshelve_copy if $self->copy;
2047
2048     if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2049         $self->copy( $self->reservation->current_resource->catalog_item );
2050     }
2051 }
2052
2053 sub booking_adjusted_due_date {
2054     my $self = shift;
2055     my $circ = $self->circ;
2056     my $copy = $self->copy;
2057
2058     return undef unless $self->use_booking;
2059
2060     my $changed;
2061
2062     if( $self->due_date ) {
2063
2064         return $self->bail_on_events($self->editor->event)
2065             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2066
2067        $circ->due_date(cleanse_ISO8601($self->due_date));
2068
2069     } else {
2070
2071         return unless $copy and $circ->due_date;
2072     }
2073
2074     my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2075     if (@$booking_items) {
2076         my $booking_item = $booking_items->[0];
2077         my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2078
2079         my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2080         my $shorten_circ_setting = $resource_type->elbow_room ||
2081             $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2082             '0 seconds';
2083
2084         my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2085         my $bookings = $booking_ses->request(
2086             'open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken,
2087             { resource => $booking_item->id, search_start => 'now', search_end => $circ->due_date, fields => { cancel_time => undef, return_time => undef}}
2088         )->gather(1);
2089         $booking_ses->disconnect;
2090         
2091         my $dt_parser = DateTime::Format::ISO8601->new;
2092         my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2093
2094         for my $bid (@$bookings) {
2095
2096             my $booking = $self->editor->retrieve_booking_reservation( $bid );
2097
2098             my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2099             my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2100
2101             return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2102                 if ($booking_start < DateTime->now);
2103
2104
2105             if ($U->is_true($stop_circ_setting)) {
2106                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ); 
2107             } else {
2108                 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2109                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now); 
2110             }
2111             
2112             # We set the circ duration here only to affect the logic that will
2113             # later (in a DB trigger) mangle the time part of the due date to
2114             # 11:59pm. Having any circ duration that is not a whole number of
2115             # days is enough to prevent the "correction."
2116             my $new_circ_duration = $due_date->epoch - time;
2117             $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2118             $circ->duration("$new_circ_duration seconds");
2119
2120             $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2121             $changed = 1;
2122         }
2123
2124         return $self->bail_on_events($self->editor->event)
2125             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2126     }
2127
2128     return $changed;
2129 }
2130
2131 sub apply_modified_due_date {
2132     my $self = shift;
2133     my $shift_earlier = shift;
2134     my $circ = $self->circ;
2135     my $copy = $self->copy;
2136
2137    if( $self->due_date ) {
2138
2139         return $self->bail_on_events($self->editor->event)
2140             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2141
2142       $circ->due_date(cleanse_ISO8601($self->due_date));
2143
2144    } else {
2145
2146       # if the due_date lands on a day when the location is closed
2147       return unless $copy and $circ->due_date;
2148
2149         #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2150
2151         # due-date overlap should be determined by the location the item
2152         # is checked out from, not the owning or circ lib of the item
2153         my $org = $self->circ_lib;
2154
2155       $logger->info("circulator: circ searching for closed date overlap on lib $org".
2156             " with an item due date of ".$circ->due_date );
2157
2158       my $dateinfo = $U->storagereq(
2159          'open-ils.storage.actor.org_unit.closed_date.overlap', 
2160             $org, $circ->due_date );
2161
2162       if($dateinfo) {
2163          $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2164             $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2165
2166             # XXX make the behavior more dynamic
2167             # for now, we just push the due date to after the close date
2168             if ($shift_earlier) {
2169                 $circ->due_date($dateinfo->{start});
2170             } else {
2171                 $circ->due_date($dateinfo->{end});
2172             }
2173       }
2174    }
2175 }
2176
2177
2178
2179 sub create_due_date {
2180     my( $self, $duration, $date_ceiling, $force_date ) = @_;
2181
2182     # if there is a raw time component (e.g. from postgres), 
2183     # turn it into an interval that interval_to_seconds can parse
2184     $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2185
2186     # for now, use the server timezone.  TODO: use workstation org timezone
2187     my $due_date = DateTime->now(time_zone => 'local');
2188
2189     # add the circ duration
2190     $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2191
2192     if($date_ceiling) {
2193         my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2194         if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2195             $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2196             $due_date = $cdate;
2197         }
2198     }
2199
2200     # return ISO8601 time with timezone
2201     return $due_date->strftime('%FT%T%z');
2202 }
2203
2204
2205
2206 sub make_precat_copy {
2207     my $self = shift;
2208     my $copy = $self->copy;
2209
2210    if($copy) {
2211         $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2212
2213         $copy->editor($self->editor->requestor->id);
2214         $copy->edit_date('now');
2215         $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2216         $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2217         $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2218         $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2219         $self->update_copy();
2220         return;
2221    }
2222
2223     $logger->info("circulator: Creating a new precataloged ".
2224         "copy in checkout with barcode " . $self->copy_barcode);
2225
2226     $copy = Fieldmapper::asset::copy->new;
2227     $copy->circ_lib($self->circ_lib);
2228     $copy->creator($self->editor->requestor->id);
2229     $copy->editor($self->editor->requestor->id);
2230     $copy->barcode($self->copy_barcode);
2231     $copy->call_number(OILS_PRECAT_CALL_NUMBER); 
2232     $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2233     $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2234
2235     $copy->dummy_title($self->dummy_title || "");
2236     $copy->dummy_author($self->dummy_author || "");
2237     $copy->dummy_isbn($self->dummy_isbn || "");
2238     $copy->circ_modifier($self->circ_modifier);
2239
2240
2241     # See if we need to override the circ_lib for the copy with a configured circ_lib
2242     # Setting is shortname of the org unit
2243     my $precat_circ_lib = $U->ou_ancestor_setting_value(
2244         $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2245
2246     if($precat_circ_lib) {
2247         my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2248
2249         if(!$org) {
2250             $self->bail_on_events($self->editor->event);
2251             return;
2252         }
2253
2254         $copy->circ_lib($org->id);
2255     }
2256
2257
2258     unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2259         $self->bail_out(1);
2260         $self->push_events($self->editor->event);
2261         return;
2262     }   
2263
2264     # this is a little bit of a hack, but we need to 
2265     # get the copy into the script runner
2266     $self->script_runner->insert("environment.copy", $copy, 1) if $self->script_runner;
2267 }
2268
2269
2270 sub checkout_noncat {
2271     my $self = shift;
2272
2273     my $circ;
2274     my $evt;
2275
2276    my $lib      = $self->noncat_circ_lib || $self->circ_lib;
2277    my $count    = $self->noncat_count || 1;
2278    my $cotime   = cleanse_ISO8601($self->checkout_time) || "";
2279
2280    $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2281
2282    for(1..$count) {
2283
2284       ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2285          $self->editor->requestor->id, 
2286             $self->patron->id, 
2287             $lib, 
2288             $self->noncat_type, 
2289             $cotime,
2290             $self->editor );
2291
2292         if( $evt ) {
2293             $self->push_events($evt);
2294             $self->bail_out(1);
2295             return; 
2296         }
2297         $self->circ($circ);
2298    }
2299 }
2300
2301 # If a copy goes into transit and is then checked in before the transit checkin 
2302 # interval has expired, push an event onto the overridable events list.
2303 sub check_transit_checkin_interval {
2304     my $self = shift;
2305
2306     # only concerned with in-transit items
2307     return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2308
2309     # no interval, no problem
2310     my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2311     return unless $interval;
2312
2313     # capture the transit so we don't have to fetch it again later during checkin
2314     $self->transit(
2315         $self->editor->search_action_transit_copy(
2316             {target_copy => $self->copy->id, dest_recv_time => undef}
2317         )->[0]
2318     ); 
2319
2320     # transit from X to X for whatever reason has no min interval
2321     return if $self->transit->source == $self->transit->dest;
2322
2323     my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2324     my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2325     my $horizon = $t_start->add(seconds => $seconds);
2326
2327     # See if we are still within the transit checkin forbidden range
2328     $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK')) 
2329         if $horizon > DateTime->now;
2330 }
2331
2332 # Retarget local holds at checkin
2333 sub checkin_retarget {
2334     my $self = shift;
2335     return unless $self->retarget_mode =~ m/retarget/; # Retargeting?
2336     return unless $self->is_checkin; # Renewals need not be checked
2337     return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2338     return if $self->is_precat; # No holds for precats
2339     return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2340     return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2341     my $status = $U->copy_status($self->copy->status);
2342     return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2343     # Specifically target items that are likely new (by status ID)
2344     return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2345     my $location = $self->copy->location;
2346     if(!ref($location)) {
2347         $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2348         $self->copy->location($location);
2349     }
2350     return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2351
2352     # Fetch holds for the bib
2353     my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2354                     $self->editor->authtoken,
2355                     $self->title->id,
2356                     {
2357                         capture_time => undef, # No touching captured holds
2358                         frozen => 'f', # Don't bother with frozen holds
2359                         pickup_lib => $self->circ_lib # Only holds actually here
2360                     }); 
2361
2362     # Error? Skip the step.
2363     return if exists $result->{"ilsevent"};
2364
2365     # Assemble holds
2366     my $holds = [];
2367     foreach my $holdlist (keys %{$result}) {
2368         push @$holds, @{$result->{$holdlist}};
2369     }
2370
2371     return if scalar(@$holds) == 0; # No holds, no retargeting
2372
2373     # Check for parts on this copy
2374     my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2375     my %parts_hash = ();
2376     %parts_hash = map {$_->part, 1} @$parts if @$parts;
2377
2378     # Loop over holds in request-ish order
2379     # Stage 1: Get them into request-ish order
2380     # Also grab type and target for skipping low hanging ones
2381     $result = $self->editor->json_query({
2382         "select" => { "ahr" => ["id", "hold_type", "target"] },
2383         "from" => { "ahr" => { "au" => { "fkey" => "usr",  "join" => "pgt"} } },
2384         "where" => { "id" => $holds },
2385         "order_by" => [
2386             { "class" => "pgt", "field" => "hold_priority"},
2387             { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2388             { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2389             { "class" => "ahr", "field" => "request_time"}
2390         ]
2391     });
2392
2393     # Stage 2: Loop!
2394     if (ref $result eq "ARRAY" and scalar @$result) {
2395         foreach (@{$result}) {
2396             # Copy level, but not this copy?
2397             next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2398                 and $_->{target} != $self->copy->id);
2399             # Volume level, but not this volume?
2400             next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2401             if(@$parts) { # We have parts?
2402                 # Skip title holds
2403                 next if ($_->{hold_type} eq 'T');
2404                 # Skip part holds for parts not on this copy
2405                 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2406             } else {
2407                 # No parts, no part holds
2408                 next if ($_->{hold_type} eq 'P');
2409             }
2410             # So much for easy stuff, attempt a retarget!
2411             my $tresult = $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $_->{id}, $self->copy->id);
2412             if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2413                 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2414             }
2415         }
2416     }
2417 }
2418
2419 sub do_checkin {
2420     my $self = shift;
2421     $self->log_me("do_checkin()");
2422
2423     return $self->bail_on_events(
2424         OpenILS::Event->new('ASSET_COPY_NOT_FOUND')) 
2425         unless $self->copy;
2426
2427     $self->check_transit_checkin_interval;
2428     $self->checkin_retarget;
2429
2430     # the renew code and mk_env should have already found our circulation object
2431     unless( $self->circ ) {
2432
2433         my $circs = $self->editor->search_action_circulation(
2434             { target_copy => $self->copy->id, checkin_time => undef });
2435
2436         $self->circ($$circs[0]);
2437
2438         # for now, just warn if there are multiple open circs on a copy
2439         $logger->warn("circulator: we have ".scalar(@$circs).
2440             " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2441     }
2442
2443     # run the fine generator against this circ, if this circ is there
2444     $self->generate_fines_start if $self->circ;
2445
2446     if( $self->checkin_check_holds_shelf() ) {
2447         $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2448         $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2449         if($self->fake_hold_dest) {
2450             $self->hold->pickup_lib($self->circ_lib);
2451         }
2452         $self->checkin_flesh_events;
2453         return;
2454     }
2455
2456     unless( $self->is_renewal ) {
2457         return $self->bail_on_events($self->editor->event)
2458             unless $self->editor->allowed('COPY_CHECKIN');
2459     }
2460
2461     $self->push_events($self->check_copy_alert());
2462     $self->push_events($self->check_checkin_copy_status());
2463
2464     # if the circ is marked as 'claims returned', add the event to the list
2465     $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2466         if ($self->circ and $self->circ->stop_fines 
2467                 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2468
2469     $self->check_circ_deposit();
2470
2471     # handle the overridable events 
2472     $self->override_events unless $self->is_renewal;
2473     return if $self->bail_out;
2474     
2475     if( $self->copy and !$self->transit ) {
2476         $self->transit(
2477             $self->editor->search_action_transit_copy(
2478                 { target_copy => $self->copy->id, dest_recv_time => undef }
2479             )->[0]
2480         ); 
2481     }
2482
2483     if( $self->circ ) {
2484         $self->generate_fines_finish;
2485         $self->checkin_handle_circ;
2486         return if $self->bail_out;
2487         $self->checkin_changed(1);
2488
2489     } elsif( $self->transit ) {
2490         my $hold_transit = $self->process_received_transit;
2491         $self->checkin_changed(1);
2492
2493         if( $self->bail_out ) { 
2494             $self->checkin_flesh_events;
2495             return;
2496         }
2497         
2498         if( my $e = $self->check_checkin_copy_status() ) {
2499             # If the original copy status is special, alert the caller
2500             my $ev = $self->events;
2501             $self->events([$e]);
2502             $self->override_events;
2503             return if $self->bail_out;
2504             $self->events($ev);
2505         }
2506
2507         if( $hold_transit or 
2508                 $U->copy_status($self->copy->status)->id 
2509                     == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2510
2511             my $hold;
2512             if( $hold_transit ) {
2513                $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2514             } else {
2515                    ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2516             }
2517
2518             $self->hold($hold);
2519
2520             if( $hold and $hold->cancel_time ) { # this transited hold was cancelled mid-transit
2521
2522                 $logger->info("circulator: we received a transit on a cancelled hold " . $hold->id);
2523                 $self->reshelve_copy(1);
2524                 $self->cancelled_hold_transit(1);
2525                 $self->notify_hold(0); # don't notify for cancelled holds
2526                 $self->fake_hold_dest(0);
2527                 return if $self->bail_out;
2528
2529             } elsif ($hold and $hold->hold_type eq 'R') {
2530
2531                 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2532                 $self->notify_hold(0); # No need to notify
2533                 $self->fake_hold_dest(0);
2534                 $self->noop(1); # Don't try and capture for other holds/transits now
2535                 $self->update_copy();
2536                 $hold->fulfillment_time('now');
2537                 $self->bail_on_events($self->editor->event)
2538                     unless $self->editor->update_action_hold_request($hold);
2539
2540             } else {
2541
2542                 # hold transited to correct location
2543                 if($self->fake_hold_dest) {
2544                     $hold->pickup_lib($self->circ_lib);
2545                 }
2546                 $self->checkin_flesh_events;
2547                 return;
2548             }
2549         } 
2550
2551     } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2552
2553         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2554             " that is in-transit, but there is no transit.. repairing");
2555         $self->reshelve_copy(1);
2556         return if $self->bail_out;
2557     }
2558
2559     if( $self->is_renewal ) {
2560         $self->finish_fines_and_voiding;
2561         return if $self->bail_out;
2562         $self->push_events(OpenILS::Event->new('SUCCESS'));
2563         return;
2564     }
2565
2566    # ------------------------------------------------------------------------------
2567    # Circulations and transits are now closed where necessary.  Now go on to see if
2568    # this copy can fulfill a hold or needs to be routed to a different location
2569    # ------------------------------------------------------------------------------
2570
2571     my $needed_for_something = 0; # formerly "needed_for_hold"
2572
2573     if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2574
2575         if (!$self->remote_hold) {
2576             if ($self->use_booking) {
2577                 my $potential_hold = $self->hold_capture_is_possible;
2578                 my $potential_reservation = $self->reservation_capture_is_possible;
2579
2580                 if ($potential_hold and $potential_reservation) {
2581                     $logger->info("circulator: item could fulfill either hold or reservation");
2582                     $self->push_events(new OpenILS::Event(
2583                         "HOLD_RESERVATION_CONFLICT",
2584                         "hold" => $potential_hold,
2585                         "reservation" => $potential_reservation
2586                     ));
2587                     return if $self->bail_out;
2588                 } elsif ($potential_hold) {
2589                     $needed_for_something =
2590                         $self->attempt_checkin_hold_capture;
2591                 } elsif ($potential_reservation) {
2592                     $needed_for_something =
2593                         $self->attempt_checkin_reservation_capture;
2594                 }
2595             } else {
2596                 $needed_for_something = $self->attempt_checkin_hold_capture;
2597             }
2598         }
2599         return if $self->bail_out;
2600     
2601         unless($needed_for_something) {
2602             my $circ_lib = (ref $self->copy->circ_lib) ? 
2603                     $self->copy->circ_lib->id : $self->copy->circ_lib;
2604     
2605             if( $self->remote_hold ) {
2606                 $circ_lib = $self->remote_hold->pickup_lib;
2607                 $logger->warn("circulator: Copy ".$self->copy->barcode.
2608                     " is on a remote hold's shelf, sending to $circ_lib");
2609             }
2610     
2611             $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2612
2613             my $suppress_transit = 0;
2614
2615             if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2616                 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2617                 if($suppress_transit_source && $suppress_transit_source->{value}) {
2618                     my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2619                     if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2620                         $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2621                         $suppress_transit = 1;
2622                     }
2623                 }
2624             }
2625  
2626             if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2627                 # copy is where it needs to be, either for hold or reshelving
2628     
2629                 $self->checkin_handle_precat();
2630                 return if $self->bail_out;
2631     
2632             } else {
2633                 # copy needs to transit "home", or stick here if it's a floating copy
2634     
2635                 if ($U->is_true( $self->copy->floating ) && !$self->remote_hold) { # copy is floating, stick here
2636                     $self->checkin_changed(1);
2637                     $self->copy->circ_lib( $self->circ_lib );
2638                     $self->update_copy;
2639                 } else {
2640                     my $bc = $self->copy->barcode;
2641                     $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2642                     $self->checkin_build_copy_transit($circ_lib);
2643                     return if $self->bail_out;
2644                     $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2645                 }
2646             }
2647         }
2648     } else { # no-op checkin
2649         if ($U->is_true( $self->copy->floating )) { # XXX floating items still stick where they are even with no-op checkin?
2650             $self->checkin_changed(1);
2651             $self->copy->circ_lib( $self->circ_lib );
2652             $self->update_copy;
2653         }
2654     }
2655
2656     if($self->claims_never_checked_out and 
2657             $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2658
2659         # the item was not supposed to be checked out to the user and should now be marked as missing
2660         $self->copy->status(OILS_COPY_STATUS_MISSING);
2661         $self->update_copy;
2662
2663     } else {
2664         $self->reshelve_copy unless $needed_for_something;
2665     }
2666
2667     return if $self->bail_out;
2668
2669     unless($self->checkin_changed) {
2670
2671         $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2672         my $stat = $U->copy_status($self->copy->status)->id;
2673
2674         $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2675          if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2676         $self->bail_out(1); # no need to commit anything
2677
2678     } else {
2679
2680         $self->push_events(OpenILS::Event->new('SUCCESS')) 
2681             unless @{$self->events};
2682     }
2683
2684     $self->finish_fines_and_voiding;
2685
2686     OpenILS::Utils::Penalty->calculate_penalties(
2687         $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2688
2689     $self->checkin_flesh_events;
2690     return;
2691 }
2692
2693 sub finish_fines_and_voiding {
2694     my $self = shift;
2695     return unless $self->circ;
2696
2697     # gather any updates to the circ after fine generation, if there was a circ
2698     $self->generate_fines_finish;
2699
2700     return unless $self->backdate or $self->void_overdues;
2701
2702     # void overdues after fine generation to prevent concurrent DB access to overdue billings
2703     my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2704
2705     my $evt = OpenILS::Application::Circ::CircCommon->void_overdues(
2706         $self->editor, $self->circ, $self->backdate, $note);
2707
2708     return $self->bail_on_events($evt) if $evt;
2709
2710     # make sure the circ isn't closed if we just voided some fines
2711     $evt = OpenILS::Application::Circ::CircCommon->reopen_xact($self->editor, $self->circ->id);
2712     return $self->bail_on_events($evt) if $evt;
2713
2714     return undef;
2715 }
2716
2717
2718 # if a deposit was payed for this item, push the event
2719 sub check_circ_deposit {
2720     my $self = shift;
2721     return unless $self->circ;
2722     my $deposit = $self->editor->search_money_billing(
2723         {   btype => 5, 
2724             xact => $self->circ->id, 
2725             voided => 'f'
2726         }, {idlist => 1})->[0];
2727
2728     $self->push_events(OpenILS::Event->new(
2729         'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2730 }
2731
2732 sub reshelve_copy {
2733    my $self    = shift;
2734    my $force   = $self->force || shift;
2735    my $copy    = $self->copy;
2736
2737    my $stat = $U->copy_status($copy->status)->id;
2738
2739    if($force || (
2740       $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2741       $stat != OILS_COPY_STATUS_CATALOGING and
2742       $stat != OILS_COPY_STATUS_IN_TRANSIT and
2743       $stat != OILS_COPY_STATUS_RESHELVING  )) {
2744
2745         $copy->status( OILS_COPY_STATUS_RESHELVING );
2746             $self->update_copy;
2747             $self->checkin_changed(1);
2748     }
2749 }
2750
2751
2752 # Returns true if the item is at the current location
2753 # because it was transited there for a hold and the 
2754 # hold has not been fulfilled
2755 sub checkin_check_holds_shelf {
2756     my $self = shift;
2757     return 0 unless $self->copy;
2758
2759     return 0 unless 
2760         $U->copy_status($self->copy->status)->id ==
2761             OILS_COPY_STATUS_ON_HOLDS_SHELF;
2762
2763     # Attempt to clear shelf expired holds for this copy
2764     $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
2765         if($self->clear_expired);
2766
2767     # find the hold that put us on the holds shelf
2768     my $holds = $self->editor->search_action_hold_request(
2769         { 
2770             current_copy => $self->copy->id,
2771             capture_time => { '!=' => undef },
2772             fulfillment_time => undef,
2773             cancel_time => undef,
2774         }
2775     );
2776
2777     unless(@$holds) {
2778         $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
2779         $self->reshelve_copy(1);
2780         return 0;
2781     }
2782
2783     my $hold = $$holds[0];
2784
2785     $logger->info("circulator: we found a captured, un-fulfilled hold [".
2786         $hold->id. "] for copy ".$self->copy->barcode);
2787
2788     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2789         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2790         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2791             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2792             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2793                 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
2794                 $self->fake_hold_dest(1);
2795                 return 1;
2796             }
2797         }
2798     }
2799
2800     if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
2801         $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
2802         return 1;
2803     }
2804
2805     $logger->info("circulator: hold is not for here..");
2806     $self->remote_hold($hold);
2807     return 0;
2808 }
2809
2810
2811 sub checkin_handle_precat {
2812     my $self    = shift;
2813    my $copy    = $self->copy;
2814
2815    if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
2816         $copy->status(OILS_COPY_STATUS_CATALOGING);
2817         $self->update_copy();
2818         $self->checkin_changed(1);
2819         $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
2820    }
2821 }
2822
2823
2824 sub checkin_build_copy_transit {
2825     my $self            = shift;
2826     my $dest            = shift;
2827     my $copy       = $self->copy;
2828     my $transit    = Fieldmapper::action::transit_copy->new;
2829
2830     # if we are transiting an item to the shelf shelf, it's a hold transit
2831     if (my $hold = $self->remote_hold) {
2832         $transit = Fieldmapper::action::hold_transit_copy->new;
2833         $transit->hold($hold->id);
2834
2835         # the item is going into transit, remove any shelf-iness
2836         if ($hold->current_shelf_lib or $hold->shelf_time) {
2837             $hold->clear_current_shelf_lib;
2838             $hold->clear_shelf_time;
2839             return $self->bail_on_events($self->editor->event)
2840                 unless $self->editor->update_action_hold_request($hold);
2841         }
2842     }
2843
2844     #$dest  ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
2845     $logger->info("circulator: transiting copy to $dest");
2846
2847     $transit->source($self->circ_lib);
2848     $transit->dest($dest);
2849     $transit->target_copy($copy->id);
2850     $transit->source_send_time('now');
2851     $transit->copy_status( $U->copy_status($copy->status)->id );
2852
2853     $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
2854
2855     if ($self->remote_hold) {
2856         return $self->bail_on_events($self->editor->event)
2857             unless $self->editor->create_action_hold_transit_copy($transit);
2858     } else {
2859         return $self->bail_on_events($self->editor->event)
2860             unless $self->editor->create_action_transit_copy($transit);
2861     }
2862
2863     $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2864     $self->update_copy;
2865     $self->checkin_changed(1);
2866 }
2867
2868
2869 sub hold_capture_is_possible {
2870     my $self = shift;
2871     my $copy = $self->copy;
2872
2873     # we've been explicitly told not to capture any holds
2874     return 0 if $self->capture eq 'nocapture';
2875
2876     # See if this copy can fulfill any holds
2877     my $hold = $holdcode->find_nearest_permitted_hold(
2878         $self->editor, $copy, $self->editor->requestor, 1 # check_only
2879     );
2880     return undef if ref $hold eq "HASH" and
2881         $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
2882     return $hold;
2883 }
2884
2885 sub reservation_capture_is_possible {
2886     my $self = shift;
2887     my $copy = $self->copy;
2888
2889     # we've been explicitly told not to capture any holds
2890     return 0 if $self->capture eq 'nocapture';
2891
2892     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2893     my $resv = $booking_ses->request(
2894         "open-ils.booking.reservations.could_capture",
2895         $self->editor->authtoken, $copy->barcode
2896     )->gather(1);
2897     $booking_ses->disconnect;
2898     if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
2899         $self->push_events($resv);
2900     } else {
2901         return $resv;
2902     }
2903 }
2904
2905 # returns true if the item was used (or may potentially be used 
2906 # in subsequent calls) to capture a hold.
2907 sub attempt_checkin_hold_capture {
2908     my $self = shift;
2909     my $copy = $self->copy;
2910
2911     # we've been explicitly told not to capture any holds
2912     return 0 if $self->capture eq 'nocapture';
2913
2914     # See if this copy can fulfill any holds
2915     my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold( 
2916         $self->editor, $copy, $self->editor->requestor );
2917
2918     if(!$hold) {
2919         $logger->debug("circulator: no potential permitted".
2920             "holds found for copy ".$copy->barcode);
2921         return 0;
2922     }
2923
2924     if($self->capture ne 'capture') {
2925         # see if this item is in a hold-capture-delay location
2926         my $location = $self->copy->location;
2927         if(!ref($location)) {
2928             $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2929             $self->copy->location($location);
2930         }
2931         if($U->is_true($location->hold_verify)) {
2932             $self->bail_on_events(
2933                 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
2934             return 1;
2935         }
2936     }
2937
2938     $self->retarget($retarget);
2939
2940     my $suppress_transit = 0;
2941     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2942         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2943         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2944             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2945             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2946                 $suppress_transit = 1;
2947                 $self->hold->pickup_lib($self->circ_lib);
2948             }
2949         }
2950     }
2951
2952     $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
2953
2954     $hold->current_copy($copy->id);
2955     $hold->capture_time('now');
2956     $self->put_hold_on_shelf($hold) 
2957         if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
2958
2959     # prevent DB errors caused by fetching 
2960     # holds from storage, and updating through cstore
2961     $hold->clear_fulfillment_time;
2962     $hold->clear_fulfillment_staff;
2963     $hold->clear_fulfillment_lib;
2964     $hold->clear_expire_time; 
2965     $hold->clear_cancel_time;
2966     $hold->clear_prev_check_time unless $hold->prev_check_time;
2967
2968     $self->bail_on_events($self->editor->event)
2969         unless $self->editor->update_action_hold_request($hold);
2970     $self->hold($hold);
2971     $self->checkin_changed(1);
2972
2973     return 0 if $self->bail_out;
2974
2975     if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
2976
2977         if ($hold->hold_type eq 'R') {
2978             $copy->status(OILS_COPY_STATUS_CATALOGING);
2979             $hold->fulfillment_time('now');
2980             $self->noop(1); # Block other transit/hold checks
2981             $self->bail_on_events($self->editor->event)
2982                 unless $self->editor->update_action_hold_request($hold);
2983         } else {
2984             # This hold was captured in the correct location
2985             $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2986             $self->push_events(OpenILS::Event->new('SUCCESS'));
2987
2988             #$self->do_hold_notify($hold->id);
2989             $self->notify_hold($hold->id);
2990         }
2991
2992     } else {
2993     
2994         # Hold needs to be picked up elsewhere.  Build a hold
2995         # transit and route the item.
2996         $self->checkin_build_hold_transit();
2997         $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2998         return 0 if $self->bail_out;
2999         $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3000     }
3001
3002     # make sure we save the copy status
3003     $self->update_copy;
3004     return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3005     return 1;
3006 }
3007
3008 sub attempt_checkin_reservation_capture {
3009     my $self = shift;
3010     my $copy = $self->copy;
3011
3012     # we've been explicitly told not to capture any holds
3013     return 0 if $self->capture eq 'nocapture';
3014
3015     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3016     my $evt = $booking_ses->request(
3017         "open-ils.booking.resources.capture_for_reservation",
3018         $self->editor->authtoken,
3019         $copy->barcode,
3020         1 # don't update copy - we probably have it locked
3021     )->gather(1);
3022     $booking_ses->disconnect;
3023
3024     if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3025         $logger->warn(
3026             "open-ils.booking.resources.capture_for_reservation " .
3027             "didn't return an event!"
3028         );
3029     } else {
3030         if (
3031             $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3032             $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3033         ) {
3034             # not-transferable is an error event we'll pass on the user
3035             $logger->warn("reservation capture attempted against non-transferable item");
3036             $self->push_events($evt);
3037             return 0;
3038         } elsif ($evt->{"textcode"} eq "SUCCESS") {
3039             # Re-retrieve copy as reservation capture may have changed
3040             # its status and whatnot.
3041             $logger->info(
3042                 "circulator: booking capture win on copy " . $self->copy->id
3043             );
3044             if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3045                 $logger->info(
3046                     "circulator: changing copy " . $self->copy->id .
3047                     "'s status from " . $self->copy->status . " to " .
3048                     $new_copy_status
3049                 );
3050                 $self->copy->status($new_copy_status);
3051                 $self->update_copy;
3052             }
3053             $self->reservation($evt->{"payload"}->{"reservation"});
3054
3055             if (exists $evt->{"payload"}->{"transit"}) {
3056                 $self->push_events(
3057                     new OpenILS::Event(
3058                         "ROUTE_ITEM",
3059                         "org" => $evt->{"payload"}->{"transit"}->dest
3060                     )
3061                 );
3062             }
3063             $self->checkin_changed(1);
3064             return 1;
3065         }
3066     }
3067     # other results are treated as "nothing to capture"
3068     return 0;
3069 }
3070
3071 sub do_hold_notify {
3072     my( $self, $holdid ) = @_;
3073
3074     my $e = new_editor(xact => 1);
3075     my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3076     $e->rollback;
3077     my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3078     $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3079
3080     $logger->info("circulator: running delayed hold notify process");
3081
3082 #   my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3083 #       hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3084
3085     my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3086         hold_id => $holdid, requestor => $self->editor->requestor);
3087
3088     $logger->debug("circulator: built hold notifier");
3089
3090     if(!$notifier->event) {
3091
3092         $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3093
3094         my $stat = $notifier->send_email_notify;
3095         if( $stat == '1' ) {
3096             $logger->info("circulator: hold notify succeeded for hold $holdid");
3097             return;
3098         } 
3099
3100         $logger->debug("circulator:  * hold notify cancelled or failed for hold $holdid");
3101
3102     } else {
3103         $logger->info("circulator: Not sending hold notification since the patron has no email address");
3104     }
3105 }
3106
3107 sub retarget_holds {
3108     my $self = shift;
3109     $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3110     my $ses = OpenSRF::AppSession->create('open-ils.storage');
3111     $ses->request('open-ils.storage.action.hold_request.copy_targeter', undef, $self->retarget);
3112     # no reason to wait for the return value
3113     return;
3114 }
3115
3116 sub checkin_build_hold_transit {
3117     my $self = shift;
3118
3119    my $copy = $self->copy;
3120    my $hold = $self->hold;
3121    my $trans = Fieldmapper::action::hold_transit_copy->new;
3122
3123     $logger->debug("circulator: building hold transit for ".$copy->barcode);
3124
3125    $trans->hold($hold->id);
3126    $trans->source($self->circ_lib);
3127    $trans->dest($hold->pickup_lib);
3128    $trans->source_send_time("now");
3129    $trans->target_copy($copy->id);
3130
3131     # when the copy gets to its destination, it will recover
3132     # this status - put it onto the holds shelf
3133    $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3134
3135     return $self->bail_on_events($self->editor->event)
3136         unless $self->editor->create_action_hold_transit_copy($trans);
3137 }
3138
3139
3140
3141 sub process_received_transit {
3142     my $self = shift;
3143     my $copy = $self->copy;
3144     my $copyid = $self->copy->id;
3145
3146     my $status_name = $U->copy_status($copy->status)->name;
3147     $logger->debug("circulator: attempting transit receive on ".
3148         "copy $copyid. Copy status is $status_name");
3149
3150     my $transit = $self->transit;