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