Lp 1835035: Refactor auto_renewal back end code
[working/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 a copy goes into transit and is then checked in before the transit checkin 
2522 # interval has expired, push an event onto the overridable events list.
2523 sub check_transit_checkin_interval {
2524     my $self = shift;
2525
2526     # only concerned with in-transit items
2527     return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2528
2529     # no interval, no problem
2530     my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2531     return unless $interval;
2532
2533     # capture the transit so we don't have to fetch it again later during checkin
2534     $self->transit(
2535         $self->editor->search_action_transit_copy(
2536             {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2537         )->[0]
2538     ); 
2539
2540     # transit from X to X for whatever reason has no min interval
2541     return if $self->transit->source == $self->transit->dest;
2542
2543     my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2544     my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2545     my $horizon = $t_start->add(seconds => $seconds);
2546
2547     # See if we are still within the transit checkin forbidden range
2548     $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK')) 
2549         if $horizon > DateTime->now;
2550 }
2551
2552 # Retarget local holds at checkin
2553 sub checkin_retarget {
2554     my $self = shift;
2555     return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2556     return unless $self->is_checkin; # Renewals need not be checked
2557     return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2558     return if $self->is_precat; # No holds for precats
2559     return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2560     return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2561     my $status = $U->copy_status($self->copy->status);
2562     return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2563     # Specifically target items that are likely new (by status ID)
2564     return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2565     my $location = $self->copy->location;
2566     if(!ref($location)) {
2567         $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2568         $self->copy->location($location);
2569     }
2570     return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2571
2572     # Fetch holds for the bib
2573     my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2574                     $self->editor->authtoken,
2575                     $self->title->id,
2576                     {
2577                         capture_time => undef, # No touching captured holds
2578                         frozen => 'f', # Don't bother with frozen holds
2579                         pickup_lib => $self->circ_lib # Only holds actually here
2580                     }); 
2581
2582     # Error? Skip the step.
2583     return if exists $result->{"ilsevent"};
2584
2585     # Assemble holds
2586     my $holds = [];
2587     foreach my $holdlist (keys %{$result}) {
2588         push @$holds, @{$result->{$holdlist}};
2589     }
2590
2591     return if scalar(@$holds) == 0; # No holds, no retargeting
2592
2593     # Check for parts on this copy
2594     my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2595     my %parts_hash = ();
2596     %parts_hash = map {$_->part, 1} @$parts if @$parts;
2597
2598     # Loop over holds in request-ish order
2599     # Stage 1: Get them into request-ish order
2600     # Also grab type and target for skipping low hanging ones
2601     $result = $self->editor->json_query({
2602         "select" => { "ahr" => ["id", "hold_type", "target"] },
2603         "from" => { "ahr" => { "au" => { "fkey" => "usr",  "join" => "pgt"} } },
2604         "where" => { "id" => $holds },
2605         "order_by" => [
2606             { "class" => "pgt", "field" => "hold_priority"},
2607             { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2608             { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2609             { "class" => "ahr", "field" => "request_time"}
2610         ]
2611     });
2612
2613     # Stage 2: Loop!
2614     if (ref $result eq "ARRAY" and scalar @$result) {
2615         foreach (@{$result}) {
2616             # Copy level, but not this copy?
2617             next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2618                 and $_->{target} != $self->copy->id);
2619             # Volume level, but not this volume?
2620             next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2621             if(@$parts) { # We have parts?
2622                 # Skip title holds
2623                 next if ($_->{hold_type} eq 'T');
2624                 # Skip part holds for parts not on this copy
2625                 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2626             } else {
2627                 # No parts, no part holds
2628                 next if ($_->{hold_type} eq 'P');
2629             }
2630             # So much for easy stuff, attempt a retarget!
2631             my $tresult = $U->simplereq(
2632                 'open-ils.hold-targeter',
2633                 'open-ils.hold-targeter.target', 
2634                 {hold => $_->{id}, find_copy => $self->copy->id}
2635             );
2636             if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2637                 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2638             }
2639         }
2640     }
2641 }
2642
2643 sub do_checkin {
2644     my $self = shift;
2645     $self->log_me("do_checkin()");
2646
2647     return $self->bail_on_events(
2648         OpenILS::Event->new('ASSET_COPY_NOT_FOUND')) 
2649         unless $self->copy;
2650
2651     $self->check_transit_checkin_interval;
2652     $self->checkin_retarget;
2653
2654     # the renew code and mk_env should have already found our circulation object
2655     unless( $self->circ ) {
2656
2657         my $circs = $self->editor->search_action_circulation(
2658             { target_copy => $self->copy->id, checkin_time => undef });
2659
2660         $self->circ($$circs[0]);
2661
2662         # for now, just warn if there are multiple open circs on a copy
2663         $logger->warn("circulator: we have ".scalar(@$circs).
2664             " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2665     }
2666
2667     my $stat = $U->copy_status($self->copy->status)->id;
2668
2669     # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2670     # differently if they are already paid for.  We need to check for this
2671     # early since overdue generation is potentially affected.
2672     my $dont_change_lost_zero = 0;
2673     if ($stat == OILS_COPY_STATUS_LOST
2674         || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2675         || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2676
2677         # LOST fine settings are controlled by the copy's circ lib, not the the
2678         # circulation's
2679         my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2680                 $self->copy->circ_lib->id : $self->copy->circ_lib;
2681         $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2682             $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2683             $self->editor) || 0;
2684
2685         # Don't assume there's always a circ based on copy status
2686         if ($dont_change_lost_zero && $self->circ) {
2687             my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2688             $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2689         }
2690
2691         $self->dont_change_lost_zero($dont_change_lost_zero);
2692     }
2693
2694     my $latest_inventory = Fieldmapper::asset::latest_inventory->new;
2695
2696     if ($self->do_inventory_update) {
2697         $latest_inventory->inventory_date('now');
2698         $latest_inventory->inventory_workstation($self->editor->requestor->wsid);
2699         $latest_inventory->copy($self->copy->id());
2700     } else {
2701         my $alci = $self->editor->search_asset_latest_inventory(
2702             {copy => $self->copy->id}
2703         );
2704         $latest_inventory = $alci->[0]
2705     }
2706     $self->latest_inventory($latest_inventory);
2707
2708     if( $self->checkin_check_holds_shelf() ) {
2709         $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2710         $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2711         if($self->fake_hold_dest) {
2712             $self->hold->pickup_lib($self->circ_lib);
2713         }
2714         $self->checkin_flesh_events;
2715         return;
2716     }
2717
2718     unless( $self->is_renewal ) {
2719         return $self->bail_on_events($self->editor->event)
2720             unless $self->editor->allowed('COPY_CHECKIN');
2721     }
2722
2723     $self->push_events($self->check_copy_alert());
2724     $self->push_events($self->check_checkin_copy_status());
2725
2726     # if the circ is marked as 'claims returned', add the event to the list
2727     $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2728         if ($self->circ and $self->circ->stop_fines 
2729                 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2730
2731     $self->check_circ_deposit();
2732
2733     # handle the overridable events 
2734     $self->override_events unless $self->is_renewal;
2735     return if $self->bail_out;
2736     
2737     if( $self->copy and !$self->transit ) {
2738         $self->transit(
2739             $self->editor->search_action_transit_copy(
2740                 { target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef }
2741             )->[0]
2742         ); 
2743     }
2744
2745     if( $self->circ ) {
2746         $self->checkin_handle_circ_start;
2747         return if $self->bail_out;
2748
2749         if (!$dont_change_lost_zero) {
2750             # if this circ is LOST and we are configured to generate overdue
2751             # fines for lost items on checkin (to fill the gap between mark
2752             # lost time and when the fines would have naturally stopped), then
2753             # stop_fines is no longer valid and should be cleared.
2754             #
2755             # stop_fines will be set again during the handle_fines() stage.
2756             # XXX should this setting come from the copy circ lib (like other
2757             # LOST settings), instead of the circulation circ lib?
2758             if ($stat == OILS_COPY_STATUS_LOST) {
2759                 $self->circ->clear_stop_fines if
2760                     $U->ou_ancestor_setting_value(
2761                         $self->circ_lib,
2762                         OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2763                         $self->editor
2764                     );
2765             }
2766
2767             # Set stop_fines when claimed never checked out
2768             $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2769
2770             # handle fines for this circ, including overdue gen if needed
2771             $self->handle_fines;
2772         }
2773
2774         $self->checkin_handle_circ_finish;
2775         return if $self->bail_out;
2776         $self->checkin_changed(1);
2777
2778     } elsif( $self->transit ) {
2779         my $hold_transit = $self->process_received_transit;
2780         $self->checkin_changed(1);
2781
2782         if( $self->bail_out ) { 
2783             $self->checkin_flesh_events;
2784             return;
2785         }
2786         
2787         if( my $e = $self->check_checkin_copy_status() ) {
2788             # If the original copy status is special, alert the caller
2789             my $ev = $self->events;
2790             $self->events([$e]);
2791             $self->override_events;
2792             return if $self->bail_out;
2793             $self->events($ev);
2794         }
2795
2796         if( $hold_transit or 
2797                 $U->copy_status($self->copy->status)->id 
2798                     == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2799
2800             my $hold;
2801             if( $hold_transit ) {
2802                $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2803             } else {
2804                    ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2805             }
2806
2807             $self->hold($hold);
2808
2809             if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2810
2811                 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2812                 $self->reshelve_copy(1);
2813                 $self->cancelled_hold_transit(1);
2814                 $self->notify_hold(0); # don't notify for cancelled holds
2815                 $self->fake_hold_dest(0);
2816                 return if $self->bail_out;
2817
2818             } elsif ($hold and $hold->hold_type eq 'R') {
2819
2820                 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2821                 $self->notify_hold(0); # No need to notify
2822                 $self->fake_hold_dest(0);
2823                 $self->noop(1); # Don't try and capture for other holds/transits now
2824                 $self->update_copy();
2825                 $hold->fulfillment_time('now');
2826                 $self->bail_on_events($self->editor->event)
2827                     unless $self->editor->update_action_hold_request($hold);
2828
2829             } else {
2830
2831                 # hold transited to correct location
2832                 if($self->fake_hold_dest) {
2833                     $hold->pickup_lib($self->circ_lib);
2834                 }
2835                 $self->checkin_flesh_events;
2836                 return;
2837             }
2838         } 
2839
2840     } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2841
2842         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2843             " that is in-transit, but there is no transit.. repairing");
2844         $self->reshelve_copy(1);
2845         return if $self->bail_out;
2846     }
2847
2848     if( $self->is_renewal ) {
2849         $self->finish_fines_and_voiding;
2850         return if $self->bail_out;
2851         $self->push_events(OpenILS::Event->new('SUCCESS'));
2852         return;
2853     }
2854
2855    # ------------------------------------------------------------------------------
2856    # Circulations and transits are now closed where necessary.  Now go on to see if
2857    # this copy can fulfill a hold or needs to be routed to a different location
2858    # ------------------------------------------------------------------------------
2859
2860     my $needed_for_something = 0; # formerly "needed_for_hold"
2861
2862     if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2863
2864         if (!$self->remote_hold) {
2865             if ($self->use_booking) {
2866                 my $potential_hold = $self->hold_capture_is_possible;
2867                 my $potential_reservation = $self->reservation_capture_is_possible;
2868
2869                 if ($potential_hold and $potential_reservation) {
2870                     $logger->info("circulator: item could fulfill either hold or reservation");
2871                     $self->push_events(new OpenILS::Event(
2872                         "HOLD_RESERVATION_CONFLICT",
2873                         "hold" => $potential_hold,
2874                         "reservation" => $potential_reservation
2875                     ));
2876                     return if $self->bail_out;
2877                 } elsif ($potential_hold) {
2878                     $needed_for_something =
2879                         $self->attempt_checkin_hold_capture;
2880                 } elsif ($potential_reservation) {
2881                     $needed_for_something =
2882                         $self->attempt_checkin_reservation_capture;
2883                 }
2884             } else {
2885                 $needed_for_something = $self->attempt_checkin_hold_capture;
2886             }
2887         }
2888         return if $self->bail_out;
2889     
2890         unless($needed_for_something) {
2891             my $circ_lib = (ref $self->copy->circ_lib) ? 
2892                     $self->copy->circ_lib->id : $self->copy->circ_lib;
2893     
2894             if( $self->remote_hold ) {
2895                 $circ_lib = $self->remote_hold->pickup_lib;
2896                 $logger->warn("circulator: Copy ".$self->copy->barcode.
2897                     " is on a remote hold's shelf, sending to $circ_lib");
2898             }
2899     
2900             $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2901
2902             my $suppress_transit = 0;
2903
2904             if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2905                 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2906                 if($suppress_transit_source && $suppress_transit_source->{value}) {
2907                     my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2908                     if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2909                         $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2910                         $suppress_transit = 1;
2911                     }
2912                 }
2913             }
2914  
2915             if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2916                 # copy is where it needs to be, either for hold or reshelving
2917     
2918                 $self->checkin_handle_precat();
2919                 return if $self->bail_out;
2920     
2921             } else {
2922                 # copy needs to transit "home", or stick here if it's a floating copy
2923                 my $can_float = 0;
2924                 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2925                     my $res = $self->editor->json_query(
2926                         {   from => [
2927                                 'evergreen.can_float',
2928                                 $self->copy->floating->id,
2929                                 $self->copy->circ_lib,
2930                                 $self->circ_lib
2931                             ]
2932                         }
2933                     );
2934                     $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res; 
2935                 }
2936                 if ($can_float) { # Yep, floating, stick here
2937                     $self->checkin_changed(1);
2938                     $self->copy->circ_lib( $self->circ_lib );
2939                     $self->update_copy;
2940                 } else {
2941                     my $bc = $self->copy->barcode;
2942                     $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2943                     $self->checkin_build_copy_transit($circ_lib);
2944                     return if $self->bail_out;
2945                     $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2946                 }
2947             }
2948         }
2949     } else { # no-op checkin
2950         if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2951             my $res = $self->editor->json_query(
2952                 {
2953                     from => [
2954                         'evergreen.can_float',
2955                         $self->copy->floating->id,
2956                         $self->copy->circ_lib,
2957                         $self->circ_lib
2958                     ]
2959                 }
2960             );
2961             if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2962                 $self->checkin_changed(1);
2963                 $self->copy->circ_lib( $self->circ_lib );
2964                 $self->update_copy;
2965             }
2966         }
2967     }
2968
2969     if($self->claims_never_checked_out and 
2970             $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2971
2972         # the item was not supposed to be checked out to the user and should now be marked as missing
2973         my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
2974         $self->copy->status($next_status);
2975         $self->update_copy;
2976
2977     } else {
2978         $self->reshelve_copy unless $needed_for_something;
2979     }
2980
2981     return if $self->bail_out;
2982
2983     unless($self->checkin_changed) {
2984
2985         $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2986         my $stat = $U->copy_status($self->copy->status)->id;
2987
2988         $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2989          if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2990         $self->bail_out(1); # no need to commit anything
2991
2992     } else {
2993
2994         $self->push_events(OpenILS::Event->new('SUCCESS')) 
2995             unless @{$self->events};
2996     }
2997
2998     $self->finish_fines_and_voiding;
2999
3000     OpenILS::Utils::Penalty->calculate_penalties(
3001         $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3002
3003     $self->checkin_flesh_events;
3004     return;
3005 }
3006
3007 sub finish_fines_and_voiding {
3008     my $self = shift;
3009     return unless $self->circ;
3010
3011     return unless $self->backdate or $self->void_overdues;
3012
3013     # void overdues after fine generation to prevent concurrent DB access to overdue billings
3014     my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3015
3016     my $evt = $CC->void_or_zero_overdues(
3017         $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3018
3019     return $self->bail_on_events($evt) if $evt;
3020
3021     # Make sure the circ is open or closed as necessary.
3022     $evt = $U->check_open_xact($self->editor, $self->circ->id);
3023     return $self->bail_on_events($evt) if $evt;
3024
3025     return undef;
3026 }
3027
3028
3029 # if a deposit was payed for this item, push the event
3030 sub check_circ_deposit {
3031     my $self = shift;
3032     return unless $self->circ;
3033     my $deposit = $self->editor->search_money_billing(
3034         {   btype => 5, 
3035             xact => $self->circ->id, 
3036             voided => 'f'
3037         }, {idlist => 1})->[0];
3038
3039     $self->push_events(OpenILS::Event->new(
3040         'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
3041 }
3042
3043 sub reshelve_copy {
3044    my $self    = shift;
3045    my $force   = $self->force || shift;
3046    my $copy    = $self->copy;
3047
3048    my $stat = $U->copy_status($copy->status)->id;
3049
3050    my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;