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