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