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