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