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