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