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