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