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