]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
Suppress Transits
[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             }
1721         }, 
1722         where => {
1723             '+ahr' => {
1724                 usr => $patron->id,
1725                 fulfillment_time => undef,
1726                 cancel_time => undef,
1727                '-or' => [
1728                     {expire_time => undef},
1729                     {expire_time => {'>' => 'now'}}
1730                 ]
1731             },
1732             '+ahcm' => {
1733                 target_copy => $self->copy->id
1734             },
1735         },
1736         order_by => {ahr => {request_time => {direction => 'asc'}}},
1737         limit => 1
1738     };
1739
1740     my $hold_info = $e->json_query($args)->[0];
1741     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1742     return undef if $U->ou_ancestor_setting_value(        
1743         $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1744
1745     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1746     $args = {
1747         select => {ahr => ['id']}, 
1748         from => {
1749             ahr => {
1750                 acp => {
1751                     field => 'id', 
1752                     fkey => 'current_copy',
1753                     type => 'left' # there may be no current_copy
1754                 }
1755             }
1756         }, 
1757         where => {
1758             '+ahr' => {
1759                 usr => $patron->id,
1760                 fulfillment_time => undef,
1761                 cancel_time => undef,
1762                '-or' => [
1763                     {expire_time => undef},
1764                     {expire_time => {'>' => 'now'}}
1765                 ]
1766             },
1767             '-or' => [
1768                 {
1769                     '+ahr' => { 
1770                         hold_type => 'V',
1771                         target => $self->volume->id
1772                     }
1773                 },
1774                 { 
1775                     '+ahr' => { 
1776                         hold_type => 'T',
1777                         target => $self->title->id
1778                     }
1779                 },
1780             ],
1781             '+acp' => {
1782                 '-or' => [
1783                     {id => undef}, # left-join copy may be nonexistent
1784                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1785                 ]
1786             }
1787         },
1788         order_by => {ahr => {request_time => {direction => 'asc'}}},
1789         limit => 1
1790     };
1791
1792     $hold_info = $e->json_query($args)->[0];
1793     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1794     return undef;
1795 }
1796
1797
1798 sub run_checkout_scripts {
1799     my $self = shift;
1800     my $nobail = shift;
1801
1802     my $evt;
1803     my $runner = $self->script_runner;
1804
1805     my $duration;
1806     my $recurring;
1807     my $max_fine;
1808     my $hard_due_date;
1809     my $duration_name;
1810     my $recurring_name;
1811     my $max_fine_name;
1812     my $hard_due_date_name;
1813
1814     if(!$self->legacy_script_support) {
1815         $self->run_indb_circ_test();
1816         $duration = $self->circ_matrix_matchpoint->duration_rule;
1817         $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
1818         $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
1819         $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1820
1821     } else {
1822
1823        $runner->load($self->circ_duration);
1824
1825        my $result = $runner->run or 
1826             throw OpenSRF::EX::ERROR ("Circ Duration Script Died: $@");
1827
1828        $duration_name   = $result->{durationRule};
1829        $recurring_name  = $result->{recurringFinesRule};
1830        $max_fine_name   = $result->{maxFine};
1831        $hard_due_date_name  = $result->{hardDueDate};
1832     }
1833
1834     $duration_name = $duration->name if $duration;
1835     if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
1836
1837         unless($duration) {
1838             ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
1839             return $self->bail_on_events($evt) if ($evt && !$nobail);
1840         
1841             ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
1842             return $self->bail_on_events($evt) if ($evt && !$nobail);
1843         
1844             ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
1845             return $self->bail_on_events($evt) if ($evt && !$nobail);
1846
1847             if($hard_due_date_name) {
1848                 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
1849                 return $self->bail_on_events($evt) if ($evt && !$nobail);
1850             }
1851         }
1852
1853     } else {
1854
1855         # The item circulates with an unlimited duration
1856         $duration   = undef;
1857         $recurring  = undef;
1858         $max_fine   = undef;
1859         $hard_due_date = undef;
1860     }
1861
1862    $self->duration_rule($duration);
1863    $self->recurring_fines_rule($recurring);
1864    $self->max_fine_rule($max_fine);
1865    $self->hard_due_date($hard_due_date);
1866 }
1867
1868
1869 sub build_checkout_circ_object {
1870     my $self = shift;
1871
1872    my $circ       = Fieldmapper::action::circulation->new;
1873    my $duration   = $self->duration_rule;
1874    my $max        = $self->max_fine_rule;
1875    my $recurring  = $self->recurring_fines_rule;
1876    my $hard_due_date    = $self->hard_due_date;
1877    my $copy       = $self->copy;
1878    my $patron     = $self->patron;
1879    my $duration_date_ceiling;
1880    my $duration_date_ceiling_force;
1881
1882     if( $duration ) {
1883
1884         my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
1885         $duration_date_ceiling = $policy->{duration_date_ceiling};
1886         $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
1887
1888         my $dname = $duration->name;
1889         my $mname = $max->name;
1890         my $rname = $recurring->name;
1891         my $hdname = ''; 
1892         if($hard_due_date) {
1893             $hdname = $hard_due_date->name;
1894         }
1895
1896         $logger->debug("circulator: building circulation ".
1897             "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
1898     
1899         $circ->duration($policy->{duration});
1900         $circ->recurring_fine($policy->{recurring_fine});
1901         $circ->duration_rule($duration->name);
1902         $circ->recurring_fine_rule($recurring->name);
1903         $circ->max_fine_rule($max->name);
1904         $circ->max_fine($policy->{max_fine});
1905         $circ->fine_interval($recurring->recurrence_interval);
1906         $circ->renewal_remaining($duration->max_renewals);
1907         $circ->grace_period($policy->{grace_period});
1908
1909     } else {
1910
1911         $logger->info("circulator: copy found with an unlimited circ duration");
1912         $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
1913         $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1914         $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
1915         $circ->renewal_remaining(0);
1916         $circ->grace_period(0);
1917     }
1918
1919    $circ->target_copy( $copy->id );
1920    $circ->usr( $patron->id );
1921    $circ->circ_lib( $self->circ_lib );
1922    $circ->workstation($self->editor->requestor->wsid) 
1923     if defined $self->editor->requestor->wsid;
1924
1925     # renewals maintain a link to the parent circulation
1926     $circ->parent_circ($self->parent_circ);
1927
1928    if( $self->is_renewal ) {
1929       $circ->opac_renewal('t') if $self->opac_renewal;
1930       $circ->phone_renewal('t') if $self->phone_renewal;
1931       $circ->desk_renewal('t') if $self->desk_renewal;
1932       $circ->renewal_remaining($self->renewal_remaining);
1933       $circ->circ_staff($self->editor->requestor->id);
1934    }
1935
1936
1937     # if the user provided an overiding checkout time,
1938     # (e.g. the checkout really happened several hours ago), then
1939     # we apply that here.  Does this need a perm??
1940     $circ->xact_start(cleanse_ISO8601($self->checkout_time))
1941         if $self->checkout_time;
1942
1943     # if a patron is renewing, 'requestor' will be the patron
1944     $circ->circ_staff($self->editor->requestor->id);
1945     $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force) ) if $circ->duration;
1946
1947     $self->circ($circ);
1948 }
1949
1950 sub do_reservation_pickup {
1951     my $self = shift;
1952
1953     $self->log_me("do_reservation_pickup()");
1954
1955     $self->reservation->pickup_time('now');
1956
1957     if (
1958         $self->reservation->current_resource &&
1959         $U->is_true($self->reservation->target_resource_type->catalog_item)
1960     ) {
1961         # We used to try to set $self->copy and $self->patron here,
1962         # but that should already be done.
1963
1964         $self->run_checkout_scripts(1);
1965
1966         my $duration   = $self->duration_rule;
1967         my $max        = $self->max_fine_rule;
1968         my $recurring  = $self->recurring_fines_rule;
1969
1970         if ($duration && $max && $recurring) {
1971             my $policy = $self->get_circ_policy($duration, $recurring, $max);
1972
1973             my $dname = $duration->name;
1974             my $mname = $max->name;
1975             my $rname = $recurring->name;
1976
1977             $logger->debug("circulator: updating reservation ".
1978                 "with duration=$dname, maxfine=$mname, recurring=$rname");
1979
1980             $self->reservation->fine_amount($policy->{recurring_fine});
1981             $self->reservation->max_fine($policy->{max_fine});
1982             $self->reservation->fine_interval($recurring->recurrence_interval);
1983         }
1984
1985         $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1986         $self->update_copy();
1987
1988     } else {
1989         $self->reservation->fine_amount(
1990             $self->reservation->target_resource_type->fine_amount
1991         );
1992         $self->reservation->max_fine(
1993             $self->reservation->target_resource_type->max_fine
1994         );
1995         $self->reservation->fine_interval(
1996             $self->reservation->target_resource_type->fine_interval
1997         );
1998     }
1999
2000     $self->update_reservation();
2001 }
2002
2003 sub do_reservation_return {
2004     my $self = shift;
2005     my $request = shift;
2006
2007     $self->log_me("do_reservation_return()");
2008
2009     if (not ref $self->reservation) {
2010         my ($reservation, $evt) =
2011             $U->fetch_booking_reservation($self->reservation);
2012         return $self->bail_on_events($evt) if $evt;
2013         $self->reservation($reservation);
2014     }
2015
2016     $self->generate_fines(1);
2017     $self->reservation->return_time('now');
2018     $self->update_reservation();
2019     $self->reshelve_copy if $self->copy;
2020
2021     if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2022         $self->copy( $self->reservation->current_resource->catalog_item );
2023     }
2024 }
2025
2026 sub booking_adjusted_due_date {
2027     my $self = shift;
2028     my $circ = $self->circ;
2029     my $copy = $self->copy;
2030
2031     return undef unless $self->use_booking;
2032
2033     my $changed;
2034
2035     if( $self->due_date ) {
2036
2037         return $self->bail_on_events($self->editor->event)
2038             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2039
2040        $circ->due_date(cleanse_ISO8601($self->due_date));
2041
2042     } else {
2043
2044         return unless $copy and $circ->due_date;
2045     }
2046
2047     my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2048     if (@$booking_items) {
2049         my $booking_item = $booking_items->[0];
2050         my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2051
2052         my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2053         my $shorten_circ_setting = $resource_type->elbow_room ||
2054             $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2055             '0 seconds';
2056
2057         my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2058         my $bookings = $booking_ses->request(
2059             'open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken,
2060             { resource => $booking_item->id, search_start => 'now', search_end => $circ->due_date, fields => { cancel_time => undef, return_time => undef}}
2061         )->gather(1);
2062         $booking_ses->disconnect;
2063         
2064         my $dt_parser = DateTime::Format::ISO8601->new;
2065         my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2066
2067         for my $bid (@$bookings) {
2068
2069             my $booking = $self->editor->retrieve_booking_reservation( $bid );
2070
2071             my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2072             my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2073
2074             return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2075                 if ($booking_start < DateTime->now);
2076
2077
2078             if ($U->is_true($stop_circ_setting)) {
2079                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ); 
2080             } else {
2081                 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2082                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now); 
2083             }
2084             
2085             # We set the circ duration here only to affect the logic that will
2086             # later (in a DB trigger) mangle the time part of the due date to
2087             # 11:59pm. Having any circ duration that is not a whole number of
2088             # days is enough to prevent the "correction."
2089             my $new_circ_duration = $due_date->epoch - time;
2090             $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2091             $circ->duration("$new_circ_duration seconds");
2092
2093             $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2094             $changed = 1;
2095         }
2096
2097         return $self->bail_on_events($self->editor->event)
2098             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2099     }
2100
2101     return $changed;
2102 }
2103
2104 sub apply_modified_due_date {
2105     my $self = shift;
2106     my $shift_earlier = shift;
2107     my $circ = $self->circ;
2108     my $copy = $self->copy;
2109
2110    if( $self->due_date ) {
2111
2112         return $self->bail_on_events($self->editor->event)
2113             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2114
2115       $circ->due_date(cleanse_ISO8601($self->due_date));
2116
2117    } else {
2118
2119       # if the due_date lands on a day when the location is closed
2120       return unless $copy and $circ->due_date;
2121
2122         #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2123
2124         # due-date overlap should be determined by the location the item
2125         # is checked out from, not the owning or circ lib of the item
2126         my $org = $self->circ_lib;
2127
2128       $logger->info("circulator: circ searching for closed date overlap on lib $org".
2129             " with an item due date of ".$circ->due_date );
2130
2131       my $dateinfo = $U->storagereq(
2132          'open-ils.storage.actor.org_unit.closed_date.overlap', 
2133             $org, $circ->due_date );
2134
2135       if($dateinfo) {
2136          $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2137             $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2138
2139             # XXX make the behavior more dynamic
2140             # for now, we just push the due date to after the close date
2141             if ($shift_earlier) {
2142                 $circ->due_date($dateinfo->{start});
2143             } else {
2144                 $circ->due_date($dateinfo->{end});
2145             }
2146       }
2147    }
2148 }
2149
2150
2151
2152 sub create_due_date {
2153     my( $self, $duration, $date_ceiling, $force_date ) = @_;
2154
2155     # if there is a raw time component (e.g. from postgres), 
2156     # turn it into an interval that interval_to_seconds can parse
2157     $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2158
2159     # for now, use the server timezone.  TODO: use workstation org timezone
2160     my $due_date = DateTime->now(time_zone => 'local');
2161
2162     # add the circ duration
2163     $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2164
2165     if($date_ceiling) {
2166         my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2167         if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2168             $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2169             $due_date = $cdate;
2170         }
2171     }
2172
2173     # return ISO8601 time with timezone
2174     return $due_date->strftime('%FT%T%z');
2175 }
2176
2177
2178
2179 sub make_precat_copy {
2180     my $self = shift;
2181     my $copy = $self->copy;
2182
2183    if($copy) {
2184         $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2185
2186         $copy->editor($self->editor->requestor->id);
2187         $copy->edit_date('now');
2188         $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2189         $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2190         $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2191         $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2192         $self->update_copy();
2193         return;
2194    }
2195
2196     $logger->info("circulator: Creating a new precataloged ".
2197         "copy in checkout with barcode " . $self->copy_barcode);
2198
2199     $copy = Fieldmapper::asset::copy->new;
2200     $copy->circ_lib($self->circ_lib);
2201     $copy->creator($self->editor->requestor->id);
2202     $copy->editor($self->editor->requestor->id);
2203     $copy->barcode($self->copy_barcode);
2204     $copy->call_number(OILS_PRECAT_CALL_NUMBER); 
2205     $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2206     $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2207
2208     $copy->dummy_title($self->dummy_title || "");
2209     $copy->dummy_author($self->dummy_author || "");
2210     $copy->dummy_isbn($self->dummy_isbn || "");
2211     $copy->circ_modifier($self->circ_modifier);
2212
2213
2214     # See if we need to override the circ_lib for the copy with a configured circ_lib
2215     # Setting is shortname of the org unit
2216     my $precat_circ_lib = $U->ou_ancestor_setting_value(
2217         $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2218
2219     if($precat_circ_lib) {
2220         my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2221
2222         if(!$org) {
2223             $self->bail_on_events($self->editor->event);
2224             return;
2225         }
2226
2227         $copy->circ_lib($org->id);
2228     }
2229
2230
2231     unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2232         $self->bail_out(1);
2233         $self->push_events($self->editor->event);
2234         return;
2235     }   
2236
2237     # this is a little bit of a hack, but we need to 
2238     # get the copy into the script runner
2239     $self->script_runner->insert("environment.copy", $copy, 1) if $self->script_runner;
2240 }
2241
2242
2243 sub checkout_noncat {
2244     my $self = shift;
2245
2246     my $circ;
2247     my $evt;
2248
2249    my $lib      = $self->noncat_circ_lib || $self->circ_lib;
2250    my $count    = $self->noncat_count || 1;
2251    my $cotime   = cleanse_ISO8601($self->checkout_time) || "";
2252
2253    $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2254
2255    for(1..$count) {
2256
2257       ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2258          $self->editor->requestor->id, 
2259             $self->patron->id, 
2260             $lib, 
2261             $self->noncat_type, 
2262             $cotime,
2263             $self->editor );
2264
2265         if( $evt ) {
2266             $self->push_events($evt);
2267             $self->bail_out(1);
2268             return; 
2269         }
2270         $self->circ($circ);
2271    }
2272 }
2273
2274 # If a copy goes into transit and is then checked in before the transit checkin 
2275 # interval has expired, push an event onto the overridable events list.
2276 sub check_transit_checkin_interval {
2277     my $self = shift;
2278
2279     # only concerned with in-transit items
2280     return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2281
2282     # no interval, no problem
2283     my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2284     return unless $interval;
2285
2286     # capture the transit so we don't have to fetch it again later during checkin
2287     $self->transit(
2288         $self->editor->search_action_transit_copy(
2289             {target_copy => $self->copy->id, dest_recv_time => undef}
2290         )->[0]
2291     ); 
2292
2293     # transit from X to X for whatever reason has no min interval
2294     return if $self->transit->source == $self->transit->dest;
2295
2296     my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2297     my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2298     my $horizon = $t_start->add(seconds => $seconds);
2299
2300     # See if we are still within the transit checkin forbidden range
2301     $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK')) 
2302         if $horizon > DateTime->now;
2303 }
2304
2305 # Retarget local holds at checkin
2306 sub checkin_retarget {
2307     my $self = shift;
2308     return unless $self->retarget_mode =~ m/retarget/; # Retargeting?
2309     return unless $self->is_checkin; # Renewals need not be checked
2310     return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2311     return if $self->is_precat; # No holds for precats
2312     return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2313     return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2314     my $status = $U->copy_status($self->copy->status);
2315     return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2316     # Specifically target items that are likely new (by status ID)
2317     return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2318     my $location = $self->copy->location;
2319     if(!ref($location)) {
2320         $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2321         $self->copy->location($location);
2322     }
2323     return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2324
2325     # Fetch holds for the bib
2326     my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2327                     $self->editor->authtoken,
2328                     $self->title->id,
2329                     {
2330                         capture_time => undef, # No touching captured holds
2331                         frozen => 'f', # Don't bother with frozen holds
2332                         pickup_lib => $self->circ_lib # Only holds actually here
2333                     }); 
2334
2335     # Error? Skip the step.
2336     return if exists $result->{"ilsevent"};
2337
2338     # Assemble holds
2339     my $holds = [];
2340     foreach my $holdlist (keys %{$result}) {
2341         push @$holds, @{$result->{$holdlist}};
2342     }
2343
2344     return if scalar(@$holds) == 0; # No holds, no retargeting
2345
2346     # Loop over holds in request-ish order
2347     # Stage 1: Get them into request-ish order
2348     # Also grab type and target for skipping low hanging ones
2349     $result = $self->editor->json_query({
2350         "select" => { "ahr" => ["id", "hold_type", "target"] },
2351         "from" => { "ahr" => { "au" => { "fkey" => "usr",  "join" => "pgt"} } },
2352         "where" => { "id" => $holds },
2353         "order_by" => [
2354             { "class" => "pgt", "field" => "hold_priority"},
2355             { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2356             { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2357             { "class" => "ahr", "field" => "request_time"}
2358         ]
2359     });
2360
2361     # Stage 2: Loop!
2362     if (ref $result eq "ARRAY" and scalar @$result) {
2363         foreach (@{$result}) {
2364             # Copy level, but not this copy?
2365             next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2366                 and $_->{target} != $self->copy->id);
2367             # Volume level, but not this volume?
2368             next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2369             # So much for easy stuff, attempt a retarget!
2370             my $tresult = $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $_->{id}, $self->copy->id);
2371             if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2372                 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2373             }
2374         }
2375     }
2376 }
2377
2378 sub do_checkin {
2379     my $self = shift;
2380     $self->log_me("do_checkin()");
2381
2382     return $self->bail_on_events(
2383         OpenILS::Event->new('ASSET_COPY_NOT_FOUND')) 
2384         unless $self->copy;
2385
2386     $self->check_transit_checkin_interval;
2387     $self->checkin_retarget;
2388
2389     # the renew code and mk_env should have already found our circulation object
2390     unless( $self->circ ) {
2391
2392         my $circs = $self->editor->search_action_circulation(
2393             { target_copy => $self->copy->id, checkin_time => undef });
2394
2395         $self->circ($$circs[0]);
2396
2397         # for now, just warn if there are multiple open circs on a copy
2398         $logger->warn("circulator: we have ".scalar(@$circs).
2399             " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2400     }
2401
2402     # run the fine generator against this circ, if this circ is there
2403     $self->generate_fines_start if $self->circ;
2404
2405     if( $self->checkin_check_holds_shelf() ) {
2406         $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2407         $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2408         if($self->fake_hold_dest) {
2409             $self->hold->pickup_lib($self->circ_lib);
2410         }
2411         $self->checkin_flesh_events;
2412         return;
2413     }
2414
2415     unless( $self->is_renewal ) {
2416         return $self->bail_on_events($self->editor->event)
2417             unless $self->editor->allowed('COPY_CHECKIN');
2418     }
2419
2420     $self->push_events($self->check_copy_alert());
2421     $self->push_events($self->check_checkin_copy_status());
2422
2423     # if the circ is marked as 'claims returned', add the event to the list
2424     $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2425         if ($self->circ and $self->circ->stop_fines 
2426                 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2427
2428     $self->check_circ_deposit();
2429
2430     # handle the overridable events 
2431     $self->override_events unless $self->is_renewal;
2432     return if $self->bail_out;
2433     
2434     if( $self->copy and !$self->transit ) {
2435         $self->transit(
2436             $self->editor->search_action_transit_copy(
2437                 { target_copy => $self->copy->id, dest_recv_time => undef }
2438             )->[0]
2439         ); 
2440     }
2441
2442     if( $self->circ ) {
2443         $self->generate_fines_finish;
2444         $self->checkin_handle_circ;
2445         return if $self->bail_out;
2446         $self->checkin_changed(1);
2447
2448     } elsif( $self->transit ) {
2449         my $hold_transit = $self->process_received_transit;
2450         $self->checkin_changed(1);
2451
2452         if( $self->bail_out ) { 
2453             $self->checkin_flesh_events;
2454             return;
2455         }
2456         
2457         if( my $e = $self->check_checkin_copy_status() ) {
2458             # If the original copy status is special, alert the caller
2459             my $ev = $self->events;
2460             $self->events([$e]);
2461             $self->override_events;
2462             return if $self->bail_out;
2463             $self->events($ev);
2464         }
2465
2466         if( $hold_transit or 
2467                 $U->copy_status($self->copy->status)->id 
2468                     == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2469
2470             my $hold;
2471             if( $hold_transit ) {
2472                $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2473             } else {
2474                    ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2475             }
2476
2477             $self->hold($hold);
2478
2479             if( $hold and $hold->cancel_time ) { # this transited hold was cancelled mid-transit
2480
2481                 $logger->info("circulator: we received a transit on a cancelled hold " . $hold->id);
2482                 $self->reshelve_copy(1);
2483                 $self->cancelled_hold_transit(1);
2484                 $self->notify_hold(0); # don't notify for cancelled holds
2485                 $self->fake_hold_dest(0);
2486                 return if $self->bail_out;
2487
2488             } else {
2489
2490                 # hold transited to correct location
2491                 if($self->fake_hold_dest) {
2492                     $hold->pickup_lib($self->circ_lib);
2493                 }
2494                 $self->checkin_flesh_events;
2495                 return;
2496             }
2497         } 
2498
2499     } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2500
2501         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2502             " that is in-transit, but there is no transit.. repairing");
2503         $self->reshelve_copy(1);
2504         return if $self->bail_out;
2505     }
2506
2507     if( $self->is_renewal ) {
2508         $self->finish_fines_and_voiding;
2509         return if $self->bail_out;
2510         $self->push_events(OpenILS::Event->new('SUCCESS'));
2511         return;
2512     }
2513
2514    # ------------------------------------------------------------------------------
2515    # Circulations and transits are now closed where necessary.  Now go on to see if
2516    # this copy can fulfill a hold or needs to be routed to a different location
2517    # ------------------------------------------------------------------------------
2518
2519     my $needed_for_something = 0; # formerly "needed_for_hold"
2520
2521     if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2522
2523         if (!$self->remote_hold) {
2524             if ($self->use_booking) {
2525                 my $potential_hold = $self->hold_capture_is_possible;
2526                 my $potential_reservation = $self->reservation_capture_is_possible;
2527
2528                 if ($potential_hold and $potential_reservation) {
2529                     $logger->info("circulator: item could fulfill either hold or reservation");
2530                     $self->push_events(new OpenILS::Event(
2531                         "HOLD_RESERVATION_CONFLICT",
2532                         "hold" => $potential_hold,
2533                         "reservation" => $potential_reservation
2534                     ));
2535                     return if $self->bail_out;
2536                 } elsif ($potential_hold) {
2537                     $needed_for_something =
2538                         $self->attempt_checkin_hold_capture;
2539                 } elsif ($potential_reservation) {
2540                     $needed_for_something =
2541                         $self->attempt_checkin_reservation_capture;
2542                 }
2543             } else {
2544                 $needed_for_something = $self->attempt_checkin_hold_capture;
2545             }
2546         }
2547         return if $self->bail_out;
2548     
2549         unless($needed_for_something) {
2550             my $circ_lib = (ref $self->copy->circ_lib) ? 
2551                     $self->copy->circ_lib->id : $self->copy->circ_lib;
2552     
2553             if( $self->remote_hold ) {
2554                 $circ_lib = $self->remote_hold->pickup_lib;
2555                 $logger->warn("circulator: Copy ".$self->copy->barcode.
2556                     " is on a remote hold's shelf, sending to $circ_lib");
2557             }
2558     
2559             $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2560
2561             my $suppress_transit = 0;
2562
2563             if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2564                 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2565                 if($suppress_transit_source && $suppress_transit_source->{value}) {
2566                     my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2567                     if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2568                         $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2569                         $suppress_transit = 1;
2570                     }
2571                 }
2572             }
2573  
2574             if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2575                 # copy is where it needs to be, either for hold or reshelving
2576     
2577                 $self->checkin_handle_precat();
2578                 return if $self->bail_out;
2579     
2580             } else {
2581                 # copy needs to transit "home", or stick here if it's a floating copy
2582     
2583                 if ($U->is_true( $self->copy->floating ) && !$self->remote_hold) { # copy is floating, stick here
2584                     $self->checkin_changed(1);
2585                     $self->copy->circ_lib( $self->circ_lib );
2586                     $self->update_copy;
2587                 } else {
2588                     my $bc = $self->copy->barcode;
2589                     $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2590                     $self->checkin_build_copy_transit($circ_lib);
2591                     return if $self->bail_out;
2592                     $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2593                 }
2594             }
2595         }
2596     } else { # no-op checkin
2597         if ($U->is_true( $self->copy->floating )) { # XXX floating items still stick where they are even with no-op checkin?
2598             $self->checkin_changed(1);
2599             $self->copy->circ_lib( $self->circ_lib );
2600             $self->update_copy;
2601         }
2602     }
2603
2604     if($self->claims_never_checked_out and 
2605             $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2606
2607         # the item was not supposed to be checked out to the user and should now be marked as missing
2608         $self->copy->status(OILS_COPY_STATUS_MISSING);
2609         $self->update_copy;
2610
2611     } else {
2612         $self->reshelve_copy unless $needed_for_something;
2613     }
2614
2615     return if $self->bail_out;
2616
2617     unless($self->checkin_changed) {
2618
2619         $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2620         my $stat = $U->copy_status($self->copy->status)->id;
2621
2622         $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2623          if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2624         $self->bail_out(1); # no need to commit anything
2625
2626     } else {
2627
2628         $self->push_events(OpenILS::Event->new('SUCCESS')) 
2629             unless @{$self->events};
2630     }
2631
2632     $self->finish_fines_and_voiding;
2633
2634     OpenILS::Utils::Penalty->calculate_penalties(
2635         $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2636
2637     $self->checkin_flesh_events;
2638     return;
2639 }
2640
2641 sub finish_fines_and_voiding {
2642     my $self = shift;
2643     return unless $self->circ;
2644
2645     # gather any updates to the circ after fine generation, if there was a circ
2646     $self->generate_fines_finish;
2647
2648     return unless $self->backdate or $self->void_overdues;
2649
2650     # void overdues after fine generation to prevent concurrent DB access to overdue billings
2651     my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2652
2653     my $evt = OpenILS::Application::Circ::CircCommon->void_overdues(
2654         $self->editor, $self->circ, $self->backdate, $note);
2655
2656     return $self->bail_on_events($evt) if $evt;
2657
2658     # make sure the circ isn't closed if we just voided some fines
2659     $evt = OpenILS::Application::Circ::CircCommon->reopen_xact($self->editor, $self->circ->id);
2660     return $self->bail_on_events($evt) if $evt;
2661
2662     return undef;
2663 }
2664
2665
2666 # if a deposit was payed for this item, push the event
2667 sub check_circ_deposit {
2668     my $self = shift;
2669     return unless $self->circ;
2670     my $deposit = $self->editor->search_money_billing(
2671         {   btype => 5, 
2672             xact => $self->circ->id, 
2673             voided => 'f'
2674         }, {idlist => 1})->[0];
2675
2676     $self->push_events(OpenILS::Event->new(
2677         'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
2678 }
2679
2680 sub reshelve_copy {
2681    my $self    = shift;
2682    my $force   = $self->force || shift;
2683    my $copy    = $self->copy;
2684
2685    my $stat = $U->copy_status($copy->status)->id;
2686
2687    if($force || (
2688       $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
2689       $stat != OILS_COPY_STATUS_CATALOGING and
2690       $stat != OILS_COPY_STATUS_IN_TRANSIT and
2691       $stat != OILS_COPY_STATUS_RESHELVING  )) {
2692
2693         $copy->status( OILS_COPY_STATUS_RESHELVING );
2694             $self->update_copy;
2695             $self->checkin_changed(1);
2696     }
2697 }
2698
2699
2700 # Returns true if the item is at the current location
2701 # because it was transited there for a hold and the 
2702 # hold has not been fulfilled
2703 sub checkin_check_holds_shelf {
2704     my $self = shift;
2705     return 0 unless $self->copy;
2706
2707     return 0 unless 
2708         $U->copy_status($self->copy->status)->id ==
2709             OILS_COPY_STATUS_ON_HOLDS_SHELF;
2710
2711     # Attempt to clear shelf expired holds for this copy
2712     $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
2713         if($self->clear_expired);
2714
2715     # find the hold that put us on the holds shelf
2716     my $holds = $self->editor->search_action_hold_request(
2717         { 
2718             current_copy => $self->copy->id,
2719             capture_time => { '!=' => undef },
2720             fulfillment_time => undef,
2721             cancel_time => undef,
2722         }
2723     );
2724
2725     unless(@$holds) {
2726         $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
2727         $self->reshelve_copy(1);
2728         return 0;
2729     }
2730
2731     my $hold = $$holds[0];
2732
2733     $logger->info("circulator: we found a captured, un-fulfilled hold [".
2734         $hold->id. "] for copy ".$self->copy->barcode);
2735
2736     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2737         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2738         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2739             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2740             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2741                 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
2742                 $self->fake_hold_dest(1);
2743                 return 1;
2744             }
2745         }
2746     }
2747
2748     if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
2749         $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
2750         return 1;
2751     }
2752
2753     $logger->info("circulator: hold is not for here..");
2754     $self->remote_hold($hold);
2755     return 0;
2756 }
2757
2758
2759 sub checkin_handle_precat {
2760     my $self    = shift;
2761    my $copy    = $self->copy;
2762
2763    if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
2764         $copy->status(OILS_COPY_STATUS_CATALOGING);
2765         $self->update_copy();
2766         $self->checkin_changed(1);
2767         $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
2768    }
2769 }
2770
2771
2772 sub checkin_build_copy_transit {
2773     my $self            = shift;
2774     my $dest            = shift;
2775     my $copy       = $self->copy;
2776    my $transit    = Fieldmapper::action::transit_copy->new;
2777
2778     #$dest  ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
2779     $logger->info("circulator: transiting copy to $dest");
2780
2781    $transit->source($self->circ_lib);
2782    $transit->dest($dest);
2783    $transit->target_copy($copy->id);
2784    $transit->source_send_time('now');
2785    $transit->copy_status( $U->copy_status($copy->status)->id );
2786
2787     $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
2788
2789     return $self->bail_on_events($self->editor->event)
2790         unless $self->editor->create_action_transit_copy($transit);
2791
2792    $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2793     $self->update_copy;
2794     $self->checkin_changed(1);
2795 }
2796
2797
2798 sub hold_capture_is_possible {
2799     my $self = shift;
2800     my $copy = $self->copy;
2801
2802     # we've been explicitly told not to capture any holds
2803     return 0 if $self->capture eq 'nocapture';
2804
2805     # See if this copy can fulfill any holds
2806     my $hold = $holdcode->find_nearest_permitted_hold(
2807         $self->editor, $copy, $self->editor->requestor, 1 # check_only
2808     );
2809     return undef if ref $hold eq "HASH" and
2810         $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
2811     return $hold;
2812 }
2813
2814 sub reservation_capture_is_possible {
2815     my $self = shift;
2816     my $copy = $self->copy;
2817
2818     # we've been explicitly told not to capture any holds
2819     return 0 if $self->capture eq 'nocapture';
2820
2821     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2822     my $resv = $booking_ses->request(
2823         "open-ils.booking.reservations.could_capture",
2824         $self->editor->authtoken, $copy->barcode
2825     )->gather(1);
2826     $booking_ses->disconnect;
2827     if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
2828         $self->push_events($resv);
2829     } else {
2830         return $resv;
2831     }
2832 }
2833
2834 # returns true if the item was used (or may potentially be used 
2835 # in subsequent calls) to capture a hold.
2836 sub attempt_checkin_hold_capture {
2837     my $self = shift;
2838     my $copy = $self->copy;
2839
2840     # we've been explicitly told not to capture any holds
2841     return 0 if $self->capture eq 'nocapture';
2842
2843     # See if this copy can fulfill any holds
2844     my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold( 
2845         $self->editor, $copy, $self->editor->requestor );
2846
2847     if(!$hold) {
2848         $logger->debug("circulator: no potential permitted".
2849             "holds found for copy ".$copy->barcode);
2850         return 0;
2851     }
2852
2853     if($self->capture ne 'capture') {
2854         # see if this item is in a hold-capture-delay location
2855         my $location = $self->copy->location;
2856         if(!ref($location)) {
2857             $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2858             $self->copy->location($location);
2859         }
2860         if($U->is_true($location->hold_verify)) {
2861             $self->bail_on_events(
2862                 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
2863             return 1;
2864         }
2865     }
2866
2867     $self->retarget($retarget);
2868
2869     $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
2870
2871     $hold->current_copy($copy->id);
2872     $hold->capture_time('now');
2873     $self->put_hold_on_shelf($hold) 
2874         if $hold->pickup_lib == $self->circ_lib;
2875
2876     # prevent DB errors caused by fetching 
2877     # holds from storage, and updating through cstore
2878     $hold->clear_fulfillment_time;
2879     $hold->clear_fulfillment_staff;
2880     $hold->clear_fulfillment_lib;
2881     $hold->clear_expire_time; 
2882     $hold->clear_cancel_time;
2883     $hold->clear_prev_check_time unless $hold->prev_check_time;
2884
2885     $self->bail_on_events($self->editor->event)
2886         unless $self->editor->update_action_hold_request($hold);
2887     $self->hold($hold);
2888     $self->checkin_changed(1);
2889
2890     return 0 if $self->bail_out;
2891
2892     my $suppress_transit = 0;
2893     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
2894         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
2895         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
2896             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
2897             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
2898                 $suppress_transit = 1;
2899                 $self->hold->pickup_lib($self->circ_lib);
2900             }
2901         }
2902     }
2903
2904     if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
2905
2906         # This hold was captured in the correct location
2907         $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
2908         $self->push_events(OpenILS::Event->new('SUCCESS'));
2909
2910         #$self->do_hold_notify($hold->id);
2911         $self->notify_hold($hold->id);
2912
2913     } else {
2914     
2915         # Hold needs to be picked up elsewhere.  Build a hold
2916         # transit and route the item.
2917         $self->checkin_build_hold_transit();
2918         $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2919         return 0 if $self->bail_out;
2920         $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
2921     }
2922
2923     # make sure we save the copy status
2924     $self->update_copy;
2925     return 1;
2926 }
2927
2928 sub attempt_checkin_reservation_capture {
2929     my $self = shift;
2930     my $copy = $self->copy;
2931
2932     # we've been explicitly told not to capture any holds
2933     return 0 if $self->capture eq 'nocapture';
2934
2935     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
2936     my $evt = $booking_ses->request(
2937         "open-ils.booking.resources.capture_for_reservation",
2938         $self->editor->authtoken,
2939         $copy->barcode,
2940         1 # don't update copy - we probably have it locked
2941     )->gather(1);
2942     $booking_ses->disconnect;
2943
2944     if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
2945         $logger->warn(
2946             "open-ils.booking.resources.capture_for_reservation " .
2947             "didn't return an event!"
2948         );
2949     } else {
2950         if (
2951             $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
2952             $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
2953         ) {
2954             # not-transferable is an error event we'll pass on the user
2955             $logger->warn("reservation capture attempted against non-transferable item");
2956             $self->push_events($evt);
2957             return 0;
2958         } elsif ($evt->{"textcode"} eq "SUCCESS") {
2959             # Re-retrieve copy as reservation capture may have changed
2960             # its status and whatnot.
2961             $logger->info(
2962                 "circulator: booking capture win on copy " . $self->copy->id
2963             );
2964             if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
2965                 $logger->info(
2966                     "circulator: changing copy " . $self->copy->id .
2967                     "'s status from " . $self->copy->status . " to " .
2968                     $new_copy_status
2969                 );
2970                 $self->copy->status($new_copy_status);
2971                 $self->update_copy;
2972             }
2973             $self->reservation($evt->{"payload"}->{"reservation"});
2974
2975             if (exists $evt->{"payload"}->{"transit"}) {
2976                 $self->push_events(
2977                     new OpenILS::Event(
2978                         "ROUTE_ITEM",
2979                         "org" => $evt->{"payload"}->{"transit"}->dest
2980                     )
2981                 );
2982             }
2983             $self->checkin_changed(1);
2984             return 1;
2985         }
2986     }
2987     # other results are treated as "nothing to capture"
2988     return 0;
2989 }
2990
2991 sub do_hold_notify {
2992     my( $self, $holdid ) = @_;
2993
2994     my $e = new_editor(xact => 1);
2995     my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
2996     $e->rollback;
2997     my $ses = OpenSRF::AppSession->create('open-ils.trigger');
2998     $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
2999
3000     $logger->info("circulator: running delayed hold notify process");
3001
3002 #   my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3003 #       hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3004
3005     my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3006         hold_id => $holdid, requestor => $self->editor->requestor);
3007
3008     $logger->debug("circulator: built hold notifier");
3009
3010     if(!$notifier->event) {
3011
3012         $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3013
3014         my $stat = $notifier->send_email_notify;
3015         if( $stat == '1' ) {
3016             $logger->info("circulator: hold notify succeeded for hold $holdid");
3017             return;
3018         } 
3019
3020         $logger->debug("circulator:  * hold notify cancelled or failed for hold $holdid");
3021
3022     } else {
3023         $logger->info("circulator: Not sending hold notification since the patron has no email address");
3024     }
3025 }
3026
3027 sub retarget_holds {
3028     my $self = shift;
3029     $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3030     my $ses = OpenSRF::AppSession->create('open-ils.storage');
3031     $ses->request('open-ils.storage.action.hold_request.copy_targeter', undef, $self->retarget);
3032     # no reason to wait for the return value
3033     return;
3034 }
3035
3036 sub checkin_build_hold_transit {
3037     my $self = shift;
3038
3039    my $copy = $self->copy;
3040    my $hold = $self->hold;
3041    my $trans = Fieldmapper::action::hold_transit_copy->new;
3042
3043     $logger->debug("circulator: building hold transit for ".$copy->barcode);
3044
3045    $trans->hold($hold->id);
3046    $trans->source($self->circ_lib);
3047    $trans->dest($hold->pickup_lib);
3048    $trans->source_send_time("now");
3049    $trans->target_copy($copy->id);
3050
3051     # when the copy gets to its destination, it will recover
3052     # this status - put it onto the holds shelf
3053    $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3054
3055     return $self->bail_on_events($self->editor->event)
3056         unless $self->editor->create_action_hold_transit_copy($trans);
3057 }
3058
3059
3060
3061 sub process_received_transit {
3062     my $self = shift;
3063     my $copy = $self->copy;
3064     my $copyid = $self->copy->id;
3065
3066     my $status_name = $U->copy_status($copy->status)->name;
3067     $logger->debug("circulator: attempting transit receive on ".
3068         "copy $copyid. Copy status is $status_name");
3069
3070     my $transit = $self->transit;
3071
3072     # Check if we are in a transit suppress range
3073     my $suppress_transit = 0;
3074     if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3075         my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ?  'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3076         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3077         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3078             my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3079             if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3080                 $suppress_transit = 1;
3081                 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3082             }
3083         }
3084     }
3085     if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3086         # - this item is in-transit to a different location
3087         # - Or we are capturing holds as transits, so why create a new transit?
3088
3089         my $tid = $transit->id; 
3090         my $loc = $self->circ_lib;
3091         my $dest = $transit->dest;
3092
3093         $logger->info("circulator: Fowarding transit on copy which is destined ".
3094             "for a different location. transit=$tid, copy=$copyid, current ".
3095             "location=$loc, destination location=$dest");
3096
3097         my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3098
3099         # grab the associated hold object if available
3100         my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3101         $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3102
3103         return $self->bail_on_events($evt);
3104     }
3105
3106     # The transit is received, set the receive time
3107     $transit->dest_recv_time('now');
3108     $self->bail_on_events($self->editor->event)
3109         unless $self->editor->update_action_transit_copy($transit);
3110
3111     my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3112
3113     $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3114     $copy->status( $transit->copy_status );
3115     $self->update_copy();
3116     return if $self->bail_out;
3117
3118     my $ishold = 0;
3119     if($hold_transit) { 
3120         my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3121
3122         # hold has arrived at destination, set shelf time
3123         $self->put_hold_on_shelf($hold);
3124         $self->bail_on_events($self->editor->event)
3125             unless $self->editor->update_action_hold_request($hold);
3126         return if $self->bail_out;
3127
3128         $self->notify_hold($hold_transit->hold);
3129         $ishold = 1;
3130     }
3131
3132     $self->push_events( 
3133         OpenILS::Event->new(
3134         'SUCCESS', 
3135         ishold => $ishold,
3136       payload => { transit => $transit, holdtransit => $hold_transit } ));
3137
3138     return $hold_transit;
3139 }
3140
3141
3142 # ------------------------------------------------------------------
3143 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3144 # ------------------------------------------------------------------
3145 sub put_hold_on_shelf {
3146     my($self, $hold) = @_;
3147
3148     $hold->shelf_time('now');
3149
3150     my $shelf_expire = $U->ou_ancestor_setting_value(
3151         $self->circ_lib, 'circ.holds.default_shelf_expire_interval', $self->editor);
3152
3153     return undef unless $shelf_expire;
3154
3155     my $seconds = OpenSRF::Utils->interval_to_seconds($shelf_expire);
3156     my $expire_time = DateTime->now->add(seconds => $seconds);
3157
3158     # if the shelf expire time overlaps with a pickup lib's 
3159     # closed date, push it out to the first open date
3160     my $dateinfo = $U->storagereq(
3161         'open-ils.storage.actor.org_unit.closed_date.overlap', 
3162         $hold->pickup_lib, $expire_time);
3163
3164     if($dateinfo) {
3165         my $dt_parser = DateTime::Format::ISO8601->new;
3166         $expire_time = $dt_parser->parse_datetime(cleanse_ISO8601($dateinfo->{end}));
3167
3168         # TODO: enable/disable time bump via setting?
3169         $expire_time->set(hour => '23', minute => '59', second => '59');
3170
3171         $logger->info("circulator: shelf_expire_time overlaps".
3172             " with closed date, pushing expire time to $expire_time");
3173     }
3174
3175     $hold->shelf_expire_time($expire_time->strftime('%FT%T%z'));
3176     return undef;
3177 }
3178
3179
3180
3181 sub generate_fines {
3182    my $self = shift;
3183    my $reservation = shift;
3184
3185    $self->generate_fines_start($reservation);
3186    $self->generate_fines_finish($reservation);
3187
3188    return undef;
3189 }
3190
3191 sub generate_fines_start {
3192    my $self = shift;
3193    my $reservation = shift;
3194    my $dt_parser = DateTime::Format::ISO8601->new;
3195
3196    my $obj = $reservation ? $self->reservation : $self->circ;
3197
3198    # If we have a grace period
3199    if($obj->can('grace_period')) {
3200       # Parse out the due date
3201       my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($obj->due_date) );
3202       # Add the grace period to the due date
3203       $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($obj->grace_period));
3204       # Don't generate fines on circs still in grace period
3205       return undef if ($due_date > DateTime->now);
3206    }
3207
3208    if (!exists($self->{_gen_fines_req})) {
3209       $self->{_gen_fines_req} = OpenSRF::AppSession->create('open-ils.storage') 
3210           ->request(
3211              'open-ils.storage.action.circulation.overdue.generate_fines',
3212              $obj->id
3213           );
3214    }
3215
3216    return undef;
3217 }
3218
3219 sub generate_fines_finish {
3220    my $self = shift;
3221    my $reservation = shift;
3222
3223    return undef unless $self->{_gen_fines_req};
3224
3225    my $id = $reservation ? $self->reservation->id : $self->circ->id;
3226
3227    $self->{_gen_fines_req}->wait_complete;
3228    delete($self->{_gen_fines_req});
3229
3230    # refresh the circ in case the fine generator set the stop_fines field
3231    $self->reservation($self->editor->retrieve_booking_reservation($id)) if $reservation;
3232    $self->circ($self->editor->retrieve_action_circulation($id)) if !$reservation;
3233
3234    return undef;
3235 }
3236
3237 sub checkin_handle_circ {
3238    my $self = shift;
3239    my $circ = $self->circ;
3240    my $copy = $self->copy;
3241    my $evt;
3242    my $obt;
3243
3244    $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3245
3246    # backdate the circ if necessary
3247    if($self->backdate) {
3248         my $evt = $self->checkin_handle_backdate;
3249         return $self->bail_on_events($evt) if $evt;
3250    }
3251
3252    if(!$circ->stop_fines) {
3253       $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3254       $circ->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3255       $circ->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3256       $circ->stop_fines_time('now');
3257       $circ->stop_fines_time($self->backdate) if $self->backdate;
3258    }
3259
3260     # Set the checkin vars since we have the item
3261     $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3262
3263     # capture the true scan time for back-dated checkins
3264     $circ->checkin_scan_time('now');
3265
3266     $circ->checkin_staff($self->editor->requestor->id);
3267     $circ->checkin_lib($self->circ_lib);
3268     $circ->checkin_workstation($self->editor->requestor->wsid);
3269
3270     my $circ_lib = (ref $self->copy->circ_lib) ?  
3271         $self->copy->circ_lib->id : $self->copy->circ_lib;
3272     my $stat = $U->copy_status($self->copy->status)->id;
3273
3274     # immediately available keeps items lost or missing items from going home before being handled
3275     my $lost_immediately_available = $U->ou_ancestor_setting_value(
3276         $circ_lib, OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE, $self->editor) || 0;
3277
3278
3279     if ( (!$lost_immediately_available) && ($circ_lib != $self->circ_lib) ) {
3280
3281         if( ($stat == OILS_COPY_STATUS_LOST or $stat == OILS_COPY_STATUS_MISSING) ) {
3282             $logger->info("circulator: not updating copy status on checkin because copy is lost/missing");
3283         } else {
3284             $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3285             $self->update_copy;
3286         }
3287
3288     } elsif ($stat == OILS_COPY_STATUS_LOST) {
3289
3290         $self->checkin_handle_lost($circ_lib);
3291
3292     } else {
3293
3294         $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3295         $self->update_copy;
3296     }
3297
3298
3299     # see if there are any fines owed on this circ.  if not, close it
3300     ($obt) = $U->fetch_mbts($circ->id, $self->editor);
3301     $circ->xact_finish('now') if( $obt and $obt->balance_owed == 0 );
3302
3303     $logger->debug("circulator: ".$obt->balance_owed." is owed on this circulation");
3304
3305     return $self->bail_on_events($self->editor->event)
3306         unless $self->editor->update_action_circulation($circ);
3307
3308     return undef;
3309 }
3310
3311
3312 # ------------------------------------------------------------------
3313 # See if we need to void billings for lost checkin
3314 # ------------------------------------------------------------------
3315 sub checkin_handle_lost {
3316     my $self = shift;
3317     my $circ_lib = shift;
3318     my $circ = $self->circ;
3319
3320     my $max_return = $U->ou_ancestor_setting_value(
3321         $circ_lib, OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3322
3323     if ($max_return) {
3324
3325         my $today = time();
3326         my @tm = reverse($circ->due_date =~ /([\d\.]+)/og);
3327         $tm[5] -= 1 if $tm[5] > 0;
3328         my $due = timelocal(int($tm[1]), int($tm[2]), int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3329
3330         my $last_chance = OpenSRF::Utils->interval_to_seconds($max_return) + int($due);
3331         $logger->info("MAX OD: ".$max_return."  DUEDATE: ".$circ->due_date."  TODAY: ".$today."  DUE: ".$due."  LAST: ".$last_chance);
3332
3333         $max_return = 0 if $today < $last_chance;
3334     }
3335
3336     if (!$max_return){  # there's either no max time to accept returns defined or we're within that time
3337
3338         my $void_lost = $U->ou_ancestor_setting_value(
3339             $circ_lib, OILS_SETTING_VOID_LOST_ON_CHECKIN, $self->editor) || 0;
3340         my $void_lost_fee = $U->ou_ancestor_setting_value(
3341             $circ_lib, OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN, $self->editor) || 0;
3342         my $restore_od = $U->ou_ancestor_setting_value(
3343             $circ_lib, OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN, $self->editor) || 0;
3344         $self->generate_lost_overdue(1) if $U->ou_ancestor_setting_value(
3345             $circ_lib, OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN, $self->editor);
3346
3347         $self->checkin_handle_lost_now_found(3) if $void_lost;
3348         $self->checkin_handle_lost_now_found(4) if $void_lost_fee;
3349         $self->checkin_handle_lost_now_found_restore_od($circ_lib) if $restore_od && ! $self->void_overdues;
3350     }
3351
3352     $self->copy->status($U->copy_status(OILS_COPY_STATUS_RESHELVING));
3353     $self->update_copy;
3354 }
3355
3356
3357 sub checkin_handle_backdate {
3358     my $self = shift;
3359
3360     # ------------------------------------------------------------------
3361     # clean up the backdate for date comparison
3362     # XXX We are currently taking the due-time from the original due-date,
3363     # not the input.  Do we need to do this?  This certainly interferes with
3364     # backdating of hourly checkouts, but that is likely a very rare case.
3365     # ------------------------------------------------------------------
3366     my $bd = cleanse_ISO8601($self->backdate);
3367     my $original_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->circ->due_date));
3368     my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3369     $bd = cleanse_ISO8601($new_date->ymd . 'T' . $original_date->strftime('%T%z'));
3370
3371     $self->backdate($bd);
3372     return undef;
3373 }
3374
3375
3376 sub check_checkin_copy_status {
3377     my $self = shift;
3378    my $copy = $self->copy;
3379
3380    my $status = $U->copy_status($copy->status)->id;
3381
3382    return undef
3383       if(   $status == OILS_COPY_STATUS_AVAILABLE   ||
3384             $status == OILS_COPY_STATUS_CHECKED_OUT ||
3385             $status == OILS_COPY_STATUS_IN_PROCESS  ||
3386             $status == OILS_COPY_STATUS_ON_HOLDS_SHELF  ||
3387             $status == OILS_COPY_STATUS_IN_TRANSIT  ||
3388             $status == OILS_COPY_STATUS_CATALOGING  ||
3389             $status == OILS_COPY_STATUS_ON_RESV_SHELF  ||
3390             $status == OILS_COPY_STATUS_RESHELVING );
3391
3392    return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3393       if( $status == OILS_COPY_STATUS_LOST );
3394
3395    return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3396       if( $status == OILS_COPY_STATUS_MISSING );
3397
3398    return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3399 }
3400
3401
3402
3403 # --------------------------------------------------------------------------
3404 # On checkin, we need to return as many relevant objects as we can
3405 # --------------------------------------------------------------------------
3406 sub checkin_flesh_events {
3407     my $self = shift;
3408
3409     if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events} 
3410         and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3411             $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3412     }
3413
3414     my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3415
3416     my $hold;
3417     if($self->hold and !$self->hold->cancel_time) {
3418         $hold = $self->hold;
3419         $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3420     }
3421
3422     if($self->circ) {
3423         # if we checked in a circulation, flesh the billing summary data
3424         $self->circ->billable_transaction(
3425             $self->editor->retrieve_money_billable_transaction([
3426                 $self->circ->id,
3427                 {flesh => 1, flesh_fields => {mbt => ['summary']}}
3428             ])
3429         );
3430     }
3431
3432     if($self->patron) {
3433         # flesh some patron fields before returning
3434         $self->patron(
3435             $self->editor->retrieve_actor_user([
3436                 $self->patron->id,
3437                 {
3438                     flesh => 1,
3439                     flesh_fields => {
3440                         au => ['card', 'billing_address', 'mailing_address']
3441                     }
3442                 }
3443             ])
3444         );
3445     }
3446
3447     for my $evt (@{$self->events}) {
3448
3449         my $payload         = {};
3450         $payload->{copy}    = $U->unflesh_copy($self->copy);
3451         $payload->{volume}  = $self->volume;
3452         $payload->{record}  = $record,
3453         $payload->{circ}    = $self->circ;
3454         $payload->{transit} = $self->transit;
3455         $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
3456         $payload->{hold}    = $hold;
3457         $payload->{patron}  = $self->patron;
3458         $payload->{reservation} = $self->reservation
3459             unless (not $self->reservation or $self->reservation->cancel_time);
3460
3461         $evt->{payload}     = $payload;
3462     }
3463 }
3464
3465 sub log_me {
3466     my( $self, $msg ) = @_;
3467     my $bc = ($self->copy) ? $self->copy->barcode :
3468         $self->barcode;
3469     $bc ||= "";
3470     my $usr = ($self->patron) ? $self->patron->id : "";
3471     $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
3472         ", recipient=$usr, copy=$bc");
3473 }
3474
3475
3476 sub do_renew {
3477     my $self = shift;
3478     $self->log_me("do_renew()");
3479
3480     # Make sure there is an open circ to renew that is not
3481     # marked as LOST, CLAIMSRETURNED, or LONGOVERDUE
3482     my $usrid = $self->patron->id if $self->patron;
3483     my $circ = $self->editor->search_action_circulation({
3484         target_copy => $self->copy->id,
3485         xact_finish => undef,
3486         checkin_time => undef,
3487         ($usrid ? (usr => $usrid) : ()),
3488         '-or' => [
3489             {stop_fines => undef},
3490             {stop_fines => OILS_STOP_FINES_MAX_FINES}
3491         ]
3492     })->[0];
3493
3494     return $self->bail_on_events($self->editor->event) unless $circ;
3495
3496     # A user is not allowed to renew another user's items without permission
3497     unless( $circ->usr eq $self->editor->requestor->id ) {
3498         return $self->bail_on_events($self->editor->events)
3499             unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
3500     }   
3501
3502     $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
3503         if $circ->renewal_remaining < 1;
3504
3505     # -----------------------------------------------------------------
3506
3507     $self->parent_circ($circ->id);
3508     $self->renewal_remaining( $circ->renewal_remaining - 1 );
3509     $self->circ($circ);
3510
3511     # Opac renewal - re-use circ library from original circ (unless told not to)
3512     if($self->opac_renewal) {
3513         unless(defined($opac_renewal_use_circ_lib)) {
3514             my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
3515             if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
3516                 $opac_renewal_use_circ_lib = 1;
3517             }
3518             else {
3519                 $opac_renewal_use_circ_lib = 0;
3520             }
3521         }
3522         $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
3523     }
3524
3525     # Run the fine generator against the old circ
3526     $self->generate_fines_start;
3527
3528     $self->run_renew_permit;
3529
3530     # Check the item in
3531     $self->do_checkin();
3532     return if $self->bail_out;
3533
3534     unless( $self->permit_override ) {
3535         $self->do_permit();
3536         return if $self->bail_out;
3537         $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
3538         $self->remove_event('ITEM_NOT_CATALOGED');
3539     }   
3540
3541     $self->override_events;
3542     return if $self->bail_out;
3543
3544     $self->events([]);
3545     $self->do_checkout();
3546 }
3547
3548
3549 sub remove_event {
3550     my( $self, $evt ) = @_;
3551     $evt = (ref $evt) ? $evt->{textcode} : $evt;
3552     $logger->debug("circulator: removing event from list: $evt");
3553     my @events = @{$self->events};
3554     $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
3555 }
3556
3557
3558 sub have_event {
3559     my( $self, $evt ) = @_;
3560     $evt = (ref $evt) ? $evt->{textcode} : $evt;
3561     return grep { $_->{textcode} eq $evt } @{$self->events};
3562 }
3563
3564
3565
3566 sub run_renew_permit {
3567     my $self = shift;
3568
3569     if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
3570         my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
3571             $self->editor, $self->copy, $self->editor->requestor, 1
3572         );
3573         $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
3574     }
3575
3576     if(!$self->legacy_script_support) {
3577         my $results = $self->run_indb_circ_test;
3578         $self->push_events($self->matrix_test_result_events)
3579             unless $self->circ_test_success;
3580     } else {
3581
3582         my $runner = $self->script_runner;
3583
3584         $runner->load($self->circ_permit_renew);
3585         my $result = $runner->run or 
3586             throw OpenSRF::EX::ERROR ("Circ Permit Renew Script Died: $@");
3587         if ($result->{"events"}) {
3588             $self->push_events(
3589                 map { new OpenILS::Event($_) } @{$result->{"events"}}
3590             );
3591             $logger->activity(
3592                 "circulator: circ_permit_renew for user " .
3593                 $self->patron->id . " returned " .
3594                 scalar(@{$result->{"events"}}) . " event(s)"
3595             );
3596         }
3597
3598         $self->mk_script_runner;
3599     }
3600
3601     $logger->debug("circulator: re-creating script runner to be safe");
3602 }
3603
3604
3605 # XXX: The primary mechanism for storing circ history is now handled
3606 # by tracking real circulation objects instead of bibs in a bucket.
3607 # However, this code is disabled by default and could be useful 
3608 # some day, so may as well leave it for now.
3609 sub append_reading_list {
3610     my $self = shift;
3611
3612     return undef unless 
3613         $self->is_checkout and 
3614         $self->patron and 
3615         $self->copy and 
3616         !$self->is_noncat;
3617
3618
3619     # verify history is globally enabled and uses the bucket mechanism
3620     my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
3621         apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
3622
3623     return undef unless $htype and $htype eq 'bucket';
3624
3625     my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
3626
3627     # verify the patron wants to retain the hisory
3628         my $setting = $e->search_actor_user_setting(
3629                 {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
3630     
3631     unless($setting and $setting->value) {
3632         $e->rollback;
3633         return undef;
3634     }
3635
3636     my $bkt = $e->search_container_copy_bucket(
3637         {owner => $self->patron->id, btype => 'circ_history'})->[0];
3638
3639     my $pos = 1;
3640
3641     if($bkt) {
3642         # find the next item position
3643         my $last_item = $e->search_container_copy_bucket_item(
3644             {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
3645         $pos = $last_item->pos + 1 if $last_item;
3646
3647     } else {
3648         # create the history bucket if necessary
3649         $bkt = Fieldmapper::container::copy_bucket->new;
3650         $bkt->owner($self->patron->id);
3651         $bkt->name('');
3652         $bkt->btype('circ_history');
3653         $bkt->pub('f');
3654         $e->create_container_copy_bucket($bkt) or return $e->die_event;
3655     }
3656
3657     my $item = Fieldmapper::container::copy_bucket_item->new;
3658
3659     $item->bucket($bkt->id);
3660     $item->target_copy($self->copy->id);
3661     $item->pos($pos);
3662
3663     $e->create_container_copy_bucket_item($item) or return $e->die_event;
3664     $e->commit;
3665
3666     return undef;
3667 }
3668
3669
3670 sub make_trigger_events {
3671     my $self = shift;
3672     return unless $self->circ;
3673     $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
3674     $U->create_events_for_hook('checkin',  $self->circ, $self->circ_lib) if $self->is_checkin;
3675     $U->create_events_for_hook('renewal',  $self->circ, $self->circ_lib) if $self->is_renewal;
3676 }
3677
3678
3679
3680 sub checkin_handle_lost_now_found {
3681     my ($self, $bill_type) = @_;
3682
3683     # ------------------------------------------------------------------
3684     # remove charge from patron's account if lost item is returned
3685     # ------------------------------------------------------------------
3686
3687     my $bills = $self->editor->search_money_billing(
3688         {
3689             xact => $self->circ->id,
3690             btype => $bill_type
3691         }
3692     );
3693
3694     $logger->debug("voiding lost item charge of  ".scalar(@$bills));
3695     for my $bill (@$bills) {
3696         if( !$U->is_true($bill->voided) ) {
3697             $logger->info("lost item returned - voiding bill ".$bill->id);
3698             $bill->voided('t');
3699             $bill->void_time('now');
3700             $bill->voider($self->editor->requestor->id);
3701             my $note = ($bill->note) ? $bill->note . "\n" : '';
3702             $bill->note("${note}System: VOIDED FOR LOST ITEM RETURNED");
3703
3704             $self->bail_on_events($self->editor->event)
3705                 unless $self->editor->update_money_billing($bill);
3706         }
3707     }
3708 }
3709
3710 sub checkin_handle_lost_now_found_restore_od {
3711     my $self = shift;
3712     my $circ_lib = shift;
3713
3714     # ------------------------------------------------------------------
3715     # restore those overdue charges voided when item was set to lost
3716     # ------------------------------------------------------------------
3717
3718     my $ods = $self->editor->search_money_billing(
3719         {
3720                 xact => $self->circ->id,
3721                 btype => 1
3722         }
3723     );
3724
3725     $logger->debug("returning overdue charges pre-lost  ".scalar(@$ods));
3726     for my $bill (@$ods) {
3727         if( $U->is_true($bill->voided) ) {
3728                 $logger->info("lost item returned - restoring overdue ".$bill->id);
3729                 $bill->voided('f');
3730                 $bill->clear_void_time;
3731                 $bill->voider($self->editor->requestor->id);
3732                 my $note = ($bill->note) ? $bill->note . "\n" : '';
3733                 $bill->note("${note}System: LOST RETURNED - OVERDUES REINSTATED");
3734
3735                 $self->bail_on_events($self->editor->event)
3736                         unless $self->editor->update_money_billing($bill);
3737         }
3738     }
3739 }
3740
3741 # ------------------------------------------------------------------
3742 # Lost-then-found item checked in.  This sub generates new overdue
3743 # fines, beyond the point of any existing and possibly voided 
3744 # overdue fines, up to the point of final checkin time (or max fine
3745 # amount).  
3746 # ------------------------------------------------------------------
3747 sub generate_lost_overdue_fines {
3748     my $self = shift;
3749     my $circ = $self->circ;
3750     my $e = $self->editor;
3751
3752     # Re-open the transaction so the fine generator can see it
3753     if($circ->xact_finish or $circ->stop_fines) {
3754         $e->xact_begin;
3755         $circ->clear_xact_finish;
3756         $circ->clear_stop_fines;
3757         $circ->clear_stop_fines_time;
3758         $e->update_action_circulation($circ) or return $e->die_event;
3759         $e->xact_commit;
3760     }
3761
3762     $e->xact_begin; # generate_fines expects an in-xact editor
3763     $self->generate_fines;
3764     $circ = $self->circ; # generate fines re-fetches the circ
3765     
3766     my $update = 0;
3767
3768     # Re-close the transaction if no money is owed
3769     my ($obt) = $U->fetch_mbts($circ->id, $e);
3770     if ($obt and $obt->balance_owed == 0) {
3771         $circ->xact_finish('now');
3772         $update = 1;
3773     }
3774
3775     # Set stop fines if the fine generator didn't have to
3776     unless($circ->stop_fines) {
3777         $circ->stop_fines(OILS_STOP_FINES_CHECKIN);
3778         $circ->stop_fines_time('now');
3779         $update = 1;
3780     }
3781
3782     # update the event data sent to the caller within the transaction
3783     $self->checkin_flesh_events;
3784
3785     if ($update) {
3786         $e->update_action_circulation($circ) or return $e->die_event;
3787         $e->commit;
3788     } else {
3789         $e->rollback;
3790     }
3791
3792     return undef;
3793 }
3794
3795 1;