LP#1779920 - Autorenew Feature
[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 );
132
133 __PACKAGE__->register_method(
134     method  => "run_method",
135     api_name    => "open-ils.circ.renew",
136     notes       => <<"    NOTES");
137     PARAMS( authtoken, circ => circ_id );
138     open-ils.circ.renew(login_session, circ_object);
139     Renews the provided circulation.  login_session is the requestor of the
140     renewal and if the logged in user is not the same as circ->usr, then
141     the logged in user must have RENEW_CIRC permissions.
142     NOTES
143
144 __PACKAGE__->register_method(
145     method   => "run_method",
146     api_name => "open-ils.circ.checkout.full"
147 );
148 __PACKAGE__->register_method(
149     method   => "run_method",
150     api_name => "open-ils.circ.checkout.full.override"
151 );
152 __PACKAGE__->register_method(
153     method   => "run_method",
154     api_name => "open-ils.circ.reservation.pickup"
155 );
156 __PACKAGE__->register_method(
157     method   => "run_method",
158     api_name => "open-ils.circ.reservation.return"
159 );
160 __PACKAGE__->register_method(
161     method   => "run_method",
162     api_name => "open-ils.circ.reservation.return.override"
163 );
164 __PACKAGE__->register_method(
165     method   => "run_method",
166     api_name => "open-ils.circ.checkout.inspect",
167     desc     => q/Returns the circ matrix test result and, on success, the rule set and matrix test object/
168 );
169
170
171 sub run_method {
172     my( $self, $conn, $auth, $args ) = @_;
173     translate_legacy_args($args);
174     $args->{override_args} = { all => 1 } unless defined $args->{override_args};
175     $args->{new_copy_alerts} ||= $self->api_level > 1 ? 1 : 0;
176     my $api = $self->api_name;
177
178     my $circulator = 
179         OpenILS::Application::Circ::Circulator->new($auth, %$args);
180
181     return circ_events($circulator) if $circulator->bail_out;
182
183     $circulator->use_booking(determine_booking_status());
184
185     # --------------------------------------------------------------------------
186     # First, check for a booking transit, as the barcode may not be a copy
187     # barcode, but a resource barcode, and nothing else in here will work
188     # --------------------------------------------------------------------------
189
190     if ($circulator->use_booking && (my $bc = $circulator->copy_barcode) && $api !~ /checkout|inspect/) { # do we have a barcode?
191         my $resources = $circulator->editor->search_booking_resource( { barcode => $bc } ); # any resources by this barcode?
192         if (@$resources) { # yes!
193
194             my $res_id_list = [ map { $_->id } @$resources ];
195             my $transit = $circulator->editor->search_action_reservation_transit_copy(
196                 [
197                     { target_copy => $res_id_list, dest => $circulator->circ_lib, dest_recv_time => undef, cancel_time => undef },
198                     { order_by => { artc => 'source_send_time' }, limit => 1 }
199                 ]
200             )->[0]; # Any transit for this barcode?
201
202             if ($transit) { # yes! unwrap it.
203
204                 my $reservation = $circulator->editor->retrieve_booking_reservation( $transit->reservation );
205                 my $res_type    = $circulator->editor->retrieve_booking_resource_type( $reservation->target_resource_type );
206
207                 my $success_event = new OpenILS::Event(
208                     "SUCCESS", "payload" => {"reservation" => $reservation}
209                 );
210                 if ($U->is_true($res_type->catalog_item)) { # is there a copy to be had here?
211                     if (my $copy = $circulator->editor->search_asset_copy([
212                         { barcode => $bc, deleted => 'f' }, $MK_ENV_FLESH
213                     ])->[0]) { # got a copy
214                         $copy->status( $transit->copy_status );
215                         $copy->editor($circulator->editor->requestor->id);
216                         $copy->edit_date('now');
217                         $circulator->editor->update_asset_copy($copy);
218                         $success_event->{"payload"}->{"record"} =
219                             $U->record_to_mvr($copy->call_number->record);
220                         $success_event->{"payload"}->{"volume"} = $copy->call_number;
221                         $copy->call_number($copy->call_number->id);
222                         $success_event->{"payload"}->{"copy"} = $copy;
223                     }
224                 }
225
226                 $transit->dest_recv_time('now');
227                 $circulator->editor->update_action_reservation_transit_copy( $transit );
228
229                 $circulator->editor->commit;
230                 # Formerly this branch just stopped here. Argh!
231                 $conn->respond_complete($success_event);
232                 return;
233             }
234         }
235     }
236
237     if ($circulator->use_booking) {
238         $circulator->is_res_checkin($circulator->is_checkin(1))
239             if $api =~ /reservation.return/ or (
240                 $api =~ /checkin/ and $circulator->seems_like_reservation()
241             );
242
243         $circulator->is_res_checkout(1) if $api =~ /reservation.pickup/;
244     }
245
246     $circulator->is_renewal(1) if $api =~ /renew/;
247     $circulator->is_autorenewal(1) if $api =~ /renew.auto/;
248     $circulator->is_checkin(1) if $api =~ /checkin/;
249     $circulator->is_checkout(1) if $api =~ /checkout/;
250     $circulator->override(1) if $api =~ /override/o;
251
252     $circulator->mk_env();
253     $circulator->noop(1) if $circulator->claims_never_checked_out;
254
255     return circ_events($circulator) if $circulator->bail_out;
256
257     if( $api =~ /checkout\.permit/ ) {
258         $circulator->do_permit();
259
260     } elsif( $api =~ /checkout.full/ ) {
261
262         # requesting a precat checkout implies that any required
263         # overrides have been performed.  Go ahead and re-override.
264         $circulator->skip_permit_key(1);
265         $circulator->override(1) if $circulator->request_precat;
266         $circulator->do_permit();
267         $circulator->is_checkout(1);
268         unless( $circulator->bail_out ) {
269             $circulator->events([]);
270             $circulator->do_checkout();
271         }
272
273     } elsif( $circulator->is_res_checkout ) {
274         $circulator->do_reservation_pickup();
275
276     } elsif( $api =~ /inspect/ ) {
277         my $data = $circulator->do_inspect();
278         $circulator->editor->rollback;
279         return $data;
280
281     } elsif( $api =~ /checkout/ ) {
282         $circulator->do_checkout();
283
284     } elsif( $circulator->is_res_checkin ) {
285         $circulator->do_reservation_return();
286         $circulator->do_checkin() if ($circulator->copy());
287     } elsif( $api =~ /checkin/ ) {
288         $circulator->do_checkin();
289
290     } elsif( $api =~ /renew/ ) {
291         $circulator->do_renew($api);
292     }
293
294     if( $circulator->bail_out ) {
295
296         my @ee;
297         # make sure no success event accidentally slip in
298         $circulator->events(
299             [ grep { $_->{textcode} ne 'SUCCESS' } @{$circulator->events} ]);
300
301         # Log the events
302         my @e = @{$circulator->events};
303         push( @ee, $_->{textcode} ) for @e;
304         $logger->info("circulator: bailing out with events: " . (join ", ", @ee));
305
306         $circulator->editor->rollback;
307
308     } else {
309
310         # checkin and reservation return can result in modifications to
311         # actor.usr.claims_never_checked_out_count without also modifying
312         # actor.last_xact_id.  Perform a no-op update on the patron to
313         # force an update to last_xact_id.
314         if ($circulator->claims_never_checked_out && $circulator->patron) {
315             $circulator->editor->update_actor_user(
316                 $circulator->editor->retrieve_actor_user($circulator->patron->id))
317                 or return $circulator->editor->die_event;
318         }
319
320         $circulator->editor->commit;
321     }
322     
323     $conn->respond_complete(circ_events($circulator));
324
325     return undef if $circulator->bail_out;
326
327     $circulator->do_hold_notify($circulator->notify_hold)
328         if $circulator->notify_hold;
329     $circulator->retarget_holds if $circulator->retarget;
330     $circulator->append_reading_list;
331     $circulator->make_trigger_events;
332     
333     return undef;
334 }
335
336 sub circ_events {
337     my $circ = shift;
338     my @e = @{$circ->events};
339     # if we have multiple events, SUCCESS should not be one of them;
340     @e = grep { $_->{textcode} ne 'SUCCESS' } @e if @e > 1;
341     return (@e == 1) ? $e[0] : \@e;
342 }
343
344
345 sub translate_legacy_args {
346     my $args = shift;
347
348     if( $$args{barcode} ) {
349         $$args{copy_barcode} = $$args{barcode};
350         delete $$args{barcode};
351     }
352
353     if( $$args{copyid} ) {
354         $$args{copy_id} = $$args{copyid};
355         delete $$args{copyid};
356     }
357
358     if( $$args{patronid} ) {
359         $$args{patron_id} = $$args{patronid};
360         delete $$args{patronid};
361     }
362
363     if( $$args{patron} and !ref($$args{patron}) ) {
364         $$args{patron_id} = $$args{patron};
365         delete $$args{patron};
366     }
367
368
369     if( $$args{noncat} ) {
370         $$args{is_noncat} = $$args{noncat};
371         delete $$args{noncat};
372     }
373
374     if( $$args{precat} ) {
375         $$args{is_precat} = $$args{request_precat} = $$args{precat};
376         delete $$args{precat};
377     }
378 }
379
380
381
382 # --------------------------------------------------------------------------
383 # This package actually manages all of the circulation logic
384 # --------------------------------------------------------------------------
385 package OpenILS::Application::Circ::Circulator;
386 use strict; use warnings;
387 use vars q/$AUTOLOAD/;
388 use DateTime;
389 use OpenILS::Utils::Fieldmapper;
390 use OpenSRF::Utils::Cache;
391 use Digest::MD5 qw(md5_hex);
392 use DateTime::Format::ISO8601;
393 use OpenILS::Utils::PermitHold;
394 use OpenSRF::Utils qw/:datetime/;
395 use OpenSRF::Utils::SettingsClient;
396 use OpenILS::Application::Circ::Holds;
397 use OpenILS::Application::Circ::Transit;
398 use OpenSRF::Utils::Logger qw(:logger);
399 use OpenILS::Utils::CStoreEditor qw/:funcs/;
400 use OpenILS::Const qw/:const/;
401 use OpenILS::Utils::Penalty;
402 use OpenILS::Application::Circ::CircCommon;
403 use Time::Local;
404
405 my $CC = "OpenILS::Application::Circ::CircCommon";
406 my $holdcode    = "OpenILS::Application::Circ::Holds";
407 my $transcode   = "OpenILS::Application::Circ::Transit";
408 my %user_groups;
409
410 sub DESTROY { }
411
412
413 # --------------------------------------------------------------------------
414 # Add a pile of automagic getter/setter methods
415 # --------------------------------------------------------------------------
416 my @AUTOLOAD_FIELDS = qw/
417     notify_hold
418     remote_hold
419     backdate
420     reservation
421     copy
422     copy_id
423     copy_barcode
424     new_copy_alerts
425     user_copy_alerts
426     system_copy_alerts
427     overrides_per_copy_alerts
428     next_copy_status
429     copy_state
430     patron
431     patron_id
432     patron_barcode
433     volume
434     title
435     is_renewal
436     is_autorenewal
437     is_checkout
438     is_res_checkout
439     is_precat
440     is_noncat
441     request_precat
442     is_checkin
443     is_res_checkin
444     noncat_type
445     editor
446     events
447     cache_handle
448     override
449     circ_permit_patron
450     circ_permit_copy
451     circ_duration
452     circ_recurring_fines
453     circ_max_fines
454     circ_permit_renew
455     circ
456     transit
457     hold
458     permit_key
459     noncat_circ_lib
460     noncat_count
461     checkout_time
462     dummy_title
463     dummy_author
464     dummy_isbn
465     circ_modifier
466     circ_lib
467     barcode
468     duration_level
469     recurring_fines_level
470     duration_rule
471     recurring_fines_rule
472     max_fine_rule
473     renewal_remaining
474     auto_renewal_remaining
475     hard_due_date
476     due_date
477     fulfilled_holds
478     transit
479     checkin_changed
480     force
481     permit_override
482     pending_checkouts
483     cancelled_hold_transit
484     opac_renewal
485     phone_renewal
486     desk_renewal
487     sip_renewal
488     retarget
489     matrix_test_result
490     circ_matrix_matchpoint
491     circ_test_success
492     is_deposit
493     is_rental
494     deposit_billing
495     rental_billing
496     capture
497     noop
498     void_overdues
499     parent_circ
500     return_patron
501     claims_never_checked_out
502     skip_permit_key
503     skip_deposit_fee
504     skip_rental_fee
505     use_booking
506     clear_expired
507     retarget_mode
508     hold_as_transit
509     fake_hold_dest
510     limit_groups
511     override_args
512     checkout_is_for_hold
513     manual_float
514     dont_change_lost_zero
515     lost_bill_options
516     needs_lost_bill_handling
517 /;
518
519
520 sub AUTOLOAD {
521     my $self = shift;
522     my $type = ref($self) or die "$self is not an object";
523     my $data = shift;
524     my $name = $AUTOLOAD;
525     $name =~ s/.*://o;   
526
527     unless (grep { $_ eq $name } @AUTOLOAD_FIELDS) {
528         $logger->error("circulator: $type: invalid autoload field: $name");
529         die "$type: invalid autoload field: $name\n" 
530     }
531
532     {
533         no strict 'refs';
534         *{"${type}::${name}"} = sub {
535             my $s = shift;
536             my $v = shift;
537             $s->{$name} = $v if defined $v;
538             return $s->{$name};
539         }
540     }
541     return $self->$name($data);
542 }
543
544
545 sub new {
546     my( $class, $auth, %args ) = @_;
547     $class = ref($class) || $class;
548     my $self = bless( {}, $class );
549
550     $self->events([]);
551     $self->editor(new_editor(xact => 1, authtoken => $auth));
552
553     unless( $self->editor->checkauth ) {
554         $self->bail_on_events($self->editor->event);
555         return $self;
556     }
557
558     $self->cache_handle(OpenSRF::Utils::Cache->new('global'));
559
560     $self->$_($args{$_}) for keys %args;
561
562     $self->circ_lib(
563         ($self->circ_lib) ? $self->circ_lib : $self->editor->requestor->ws_ou);
564
565     # if this is a renewal, default to desk_renewal
566     $self->desk_renewal(1) unless 
567         $self->opac_renewal or $self->phone_renewal or $self->sip_renewal;
568
569     $self->capture('') unless $self->capture;
570
571     unless(%user_groups) {
572         my $gps = $self->editor->retrieve_all_permission_grp_tree;
573         %user_groups = map { $_->id => $_ } @$gps;
574     }
575
576     return $self;
577 }
578
579
580 # --------------------------------------------------------------------------
581 # True if we should discontinue processing
582 # --------------------------------------------------------------------------
583 sub bail_out {
584     my( $self, $bool ) = @_;
585     if( defined $bool ) {
586         $logger->info("circulator: BAILING OUT") if $bool;
587         $self->{bail_out} = $bool;
588     }
589     return $self->{bail_out};
590 }
591
592
593 sub push_events {
594     my( $self, @evts ) = @_;
595     for my $e (@evts) {
596         next unless $e;
597         $e->{payload} = $self->copy if 
598               ($e->{textcode} eq 'COPY_NOT_AVAILABLE');
599
600         $logger->info("circulator: pushing event ".$e->{textcode});
601         push( @{$self->events}, $e ) unless
602             grep { $_->{textcode} eq $e->{textcode} } @{$self->events};
603     }
604 }
605
606 sub mk_permit_key {
607     my $self = shift;
608     return '' if $self->skip_permit_key;
609     my $key = md5_hex( time() . rand() . "$$" );
610     $self->cache_handle->put_cache( "oils_permit_key_$key", 1, 300 );
611     return $self->permit_key($key);
612 }
613
614 sub check_permit_key {
615     my $self = shift;
616     return 1 if $self->skip_permit_key;
617     my $key = $self->permit_key;
618     return 0 unless $key;
619     my $k = "oils_permit_key_$key";
620     my $one = $self->cache_handle->get_cache($k);
621     $self->cache_handle->delete_cache($k);
622     return ($one) ? 1 : 0;
623 }
624
625 sub seems_like_reservation {
626     my $self = shift;
627
628     # Some words about the following method:
629     # 1) It requires the VIEW_USER permission, but that's not an
630     # issue, right, since all staff should have that?
631     # 2) It returns only one reservation at a time, even if an item can be
632     # and is currently overbooked.  Hmmm....
633     my $booking_ses = create OpenSRF::AppSession("open-ils.booking");
634     my $result = $booking_ses->request(
635         "open-ils.booking.reservations.by_returnable_resource_barcode",
636         $self->editor->authtoken,
637         $self->copy_barcode
638     )->gather(1);
639     $booking_ses->disconnect;
640
641     return $self->bail_on_events($result) if defined $U->event_code($result);
642
643     if (@$result > 0) {
644         $self->reservation(shift @$result);
645         return 1;
646     } else {
647         return 0;
648     }
649
650 }
651
652 # save_trimmed_copy() used just to be a block in mk_env(), but was separated for re-use
653 sub save_trimmed_copy {
654     my ($self, $copy) = @_;
655
656     $self->copy($copy);
657     $self->volume($copy->call_number);
658     $self->title($self->volume->record);
659     $self->copy->call_number($self->volume->id);
660     $self->volume->record($self->title->id);
661     $self->is_precat(1) if $self->volume->id == OILS_PRECAT_CALL_NUMBER;
662     if($self->copy->deposit_amount and $self->copy->deposit_amount > 0) {
663         $self->is_deposit(1) if $U->is_true($self->copy->deposit);
664         $self->is_rental(1) unless $U->is_true($self->copy->deposit);
665     }
666 }
667
668 sub collect_user_copy_alerts {
669     my $self = shift;
670     my $e = $self->editor;
671
672     if($self->copy) {
673         my $alerts = $e->search_asset_copy_alert([
674             {copy => $self->copy->id, ack_time => undef},
675             {flesh => 1, flesh_fields => { aca => [ qw/ alert_type / ] }}
676         ]);
677         if (ref $alerts eq "ARRAY") {
678             $logger->info("circulator: found " . scalar(@$alerts) . " alerts for copy " .
679                 $self->copy->id);
680             $self->user_copy_alerts($alerts);
681         }
682     }
683 }
684
685 sub filter_user_copy_alerts {
686     my $self = shift;
687
688     my $e = $self->editor;
689
690     if(my $alerts = $self->user_copy_alerts) {
691
692         my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
693         my $suppressions = $e->search_actor_copy_alert_suppress(
694             {org => $suppress_orgs}
695         );
696
697         my @final_alerts;
698         foreach my $a (@$alerts) {
699             # filter on event type
700             if (defined $a->alert_type) {
701                 next if ($a->alert_type->event eq 'CHECKIN' && !$self->is_checkin && !$self->is_renewal);
702                 next if ($a->alert_type->event eq 'CHECKOUT' && !$self->is_checkout && !$self->is_renewal);
703                 next if (defined $a->alert_type->in_renew && $U->is_true($a->alert_type->in_renew) && !$self->is_renewal);
704                 next if (defined $a->alert_type->in_renew && !$U->is_true($a->alert_type->in_renew) && $self->is_renewal);
705             }
706
707             # filter on suppression
708             next if (grep { $a->alert_type->id == $_->alert_type} @$suppressions);
709
710             # filter on "only at circ lib"
711             if (defined $a->alert_type->at_circ) {
712                 my $copy_circ_lib = (ref $self->copy->circ_lib) ?
713                     $self->copy->circ_lib->id : $self->copy->circ_lib;
714                 my $orgs = $U->get_org_descendants($copy_circ_lib);
715
716                 if ($U->is_true($a->alert_type->invert_location)) {
717                     next if (grep {$_ == $self->circ_lib} @$orgs);
718                 } else {
719                     next unless (grep {$_ == $self->circ_lib} @$orgs);
720                 }
721             }
722
723             # filter on "only at owning lib"
724             if (defined $a->alert_type->at_owning) {
725                 my $copy_owning_lib = (ref $self->volume->owning_lib) ?
726                     $self->volume->owning_lib->id : $self->volume->owning_lib;
727                 my $orgs = $U->get_org_descendants($copy_owning_lib);
728
729                 if ($U->is_true($a->alert_type->invert_location)) {
730                     next if (grep {$_ == $self->circ_lib} @$orgs);
731                 } else {
732                     next unless (grep {$_ == $self->circ_lib} @$orgs);
733                 }
734             }
735
736             $a->alert_type->next_status([$U->unique_unnested_numbers($a->alert_type->next_status)]);
737
738             push @final_alerts, $a;
739         }
740
741         $self->user_copy_alerts(\@final_alerts);
742     }
743 }
744
745 sub generate_system_copy_alerts {
746     my $self = shift;
747     return unless($self->copy);
748
749     # don't create system copy alerts if the copy
750     # is in a normal state; we're assuming that there's
751     # never a need to generate a popup for each and every
752     # checkin or checkout of normal items. If this assumption
753     # proves false, then we'll need to add a way to explicitly specify
754     # that a copy alert type should never generate a system copy alert
755     return if $self->copy_state eq 'NORMAL';
756
757     my $e = $self->editor;
758
759     my $suppress_orgs = $U->get_org_full_path($self->circ_lib);
760     my $suppressions = $e->search_actor_copy_alert_suppress(
761         {org => $suppress_orgs}
762     );
763
764     # events we care about ...
765     my $event = [];
766     push(@$event, 'CHECKIN') if $self->is_checkin;
767     push(@$event, 'CHECKOUT') if $self->is_checkout;
768     return unless scalar(@$event);
769
770     my $alert_orgs = $U->get_org_ancestors($self->circ_lib);
771     my $alert_types = $e->search_config_copy_alert_type({
772         active    => 't',
773         scope_org => $alert_orgs,
774         event     => $event,
775         state => $self->copy_state,
776         '-or' => [ { in_renew => $self->is_renewal }, { in_renew => undef } ],
777     });
778
779     my @final_types;
780     foreach my $a (@$alert_types) {
781         # filter on "only at circ lib"
782         if (defined $a->at_circ) {
783             my $copy_circ_lib = (ref $self->copy->circ_lib) ?
784                 $self->copy->circ_lib->id : $self->copy->circ_lib;
785             my $orgs = $U->get_org_descendants($copy_circ_lib);
786
787             if ($U->is_true($a->invert_location)) {
788                 next if (grep {$_ == $self->circ_lib} @$orgs);
789             } else {
790                 next unless (grep {$_ == $self->circ_lib} @$orgs);
791             }
792         }
793
794         # filter on "only at owning lib"
795         if (defined $a->at_owning) {
796             my $copy_owning_lib = (ref $self->volume->owning_lib) ?
797                 $self->volume->owning_lib->id : $self->volume->owning_lib;
798             my $orgs = $U->get_org_descendants($copy_owning_lib);
799
800             if ($U->is_true($a->invert_location)) {
801                 next if (grep {$_ == $self->circ_lib} @$orgs);
802             } else {
803                 next unless (grep {$_ == $self->circ_lib} @$orgs);
804             }
805         }
806
807         push @final_types, $a;
808     }
809
810     if (@final_types) {
811         $logger->info("circulator: found " . scalar(@final_types) . " system alert types for copy" .
812             $self->copy->id);
813     }
814
815     my @alerts;
816     
817     # keep track of conditions corresponding to suppressed
818     # system alerts, as these may be used to overridee
819     # certain old-style-events
820     my %auto_override_conditions = ();
821     foreach my $t (@final_types) {
822         if ($t->next_status) {
823             if (grep { $t->id == $_->alert_type } @$suppressions) {
824                 $t->next_status([]);
825             } else {
826                 $t->next_status([$U->unique_unnested_numbers($t->next_status)]);
827             }
828         }
829
830         my $alert = new Fieldmapper::asset::copy_alert ();
831         $alert->alert_type($t->id);
832         $alert->copy($self->copy->id);
833         $alert->temp(1);
834         $alert->create_staff($e->requestor->id);
835         $alert->create_time('now');
836         $alert->ack_staff($e->requestor->id);
837         $alert->ack_time('now');
838
839         $alert = $e->create_asset_copy_alert($alert);
840
841         next unless $alert;
842
843         $alert->alert_type($t->clone);
844
845         push(@{$self->next_copy_status}, @{$t->next_status}) if ($t->next_status);
846         if (grep {$_->alert_type == $t->id} @$suppressions) {
847             $auto_override_conditions{join("\t", $t->state, $t->event)} = 1;
848         }
849         push(@alerts, $alert) unless (grep {$_->alert_type == $t->id} @$suppressions);
850     }
851
852     $self->system_copy_alerts(\@alerts);
853     $self->overrides_per_copy_alerts(\%auto_override_conditions);
854 }
855
856 sub add_overrides_from_system_copy_alerts {
857     my $self = shift;
858     my $e = $self->editor;
859
860     foreach my $condition (keys %{$self->overrides_per_copy_alerts()}) {
861         if (exists $COPY_ALERT_OVERRIDES{$condition}) {
862             $self->override(1);
863             push @{$self->override_args->{events}}, @{ $COPY_ALERT_OVERRIDES{$condition} };
864             # special handling for long-overdue and lost checkouts
865             if (grep { $_ eq 'OPEN_CIRCULATION_EXISTS' } @{ $COPY_ALERT_OVERRIDES{$condition} }) {
866                 my $state = (split /\t/, $condition, -1)[0];
867                 my $setting;
868                 if ($state eq 'LOST' or $state eq 'LOST_AND_PAID') {
869                     $setting = 'circ.copy_alerts.forgive_fines_on_lost_checkin';
870                 } elsif ($state eq 'LONGOVERDUE') {
871                     $setting = 'circ.copy_alerts.forgive_fines_on_long_overdue_checkin';
872                 } else {
873                     next;
874                 }
875                 my $forgive = $U->ou_ancestor_setting_value(
876                     $self->circ_lib, $setting, $e
877                 );
878                 if ($U->is_true($forgive)) {
879                     $self->void_overdues(1);
880                 }
881                 $self->noop(1); # do not attempt transits, just check it in
882                 $self->do_checkin();
883             }
884         }
885     }
886 }
887
888 sub mk_env {
889     my $self = shift;
890     my $e = $self->editor;
891
892     $self->next_copy_status([]) unless (defined $self->next_copy_status);
893     $self->overrides_per_copy_alerts({}) unless (defined $self->overrides_per_copy_alerts);
894
895     # --------------------------------------------------------------------------
896     # Grab the fleshed copy
897     # --------------------------------------------------------------------------
898     unless($self->is_noncat) {
899         my $copy;
900         if($self->copy_id) {
901             $copy = $e->retrieve_asset_copy(
902                 [$self->copy_id, $MK_ENV_FLESH ]) or return $e->event;
903     
904         } elsif( $self->copy_barcode ) {
905     
906             $copy = $e->search_asset_copy(
907                 [{barcode => $self->copy_barcode, deleted => 'f'}, $MK_ENV_FLESH ])->[0];
908         } elsif( $self->reservation ) {
909             my $res = $e->json_query(
910                 {
911                     "select" => {"acp" => ["id"]},
912                     "from" => {
913                         "acp" => {
914                             "brsrc" => {
915                                 "fkey" => "barcode",
916                                 "field" => "barcode",
917                                 "join" => {
918                                     "bresv" => {
919                                         "fkey" => "id",
920                                         "field" => "current_resource"
921                                     }
922                                 }
923                             }
924                         }
925                     },
926                     "where" => {
927                         deleted => 'f',
928                         "+bresv" => {
929                             "id" => (ref $self->reservation) ?
930                                 $self->reservation->id : $self->reservation
931                         }
932                     }
933                 }
934             );
935             if (ref $res eq "ARRAY" and scalar @$res) {
936                 $logger->info("circulator: mapped reservation " .
937                     $self->reservation . " to copy " . $res->[0]->{"id"});
938                 $copy = $e->retrieve_asset_copy([$res->[0]->{"id"}, $MK_ENV_FLESH]);
939             }
940         }
941     
942         if($copy) {
943             $self->save_trimmed_copy($copy);
944
945             # alerts!
946             $self->copy_state(
947                 $e->json_query(
948                     {from => ['asset.copy_state', $copy->id]}
949                 )->[0]{'asset.copy_state'}
950             );
951
952             $self->generate_system_copy_alerts;
953             $self->add_overrides_from_system_copy_alerts;
954             $self->collect_user_copy_alerts;
955             $self->filter_user_copy_alerts;
956
957         } else {
958             # We can't renew if there is no copy
959             return $self->bail_on_events(OpenILS::Event->new('ASSET_COPY_NOT_FOUND'))
960                 if $self->is_renewal;
961             $self->is_precat(1);
962         }
963     }
964
965     # --------------------------------------------------------------------------
966     # Grab the patron
967     # --------------------------------------------------------------------------
968     my $patron;
969     my $flesh = {
970         flesh => 1,
971         flesh_fields => {au => [ qw/ card / ]}
972     };
973
974     if( $self->patron_id ) {
975         $patron = $e->retrieve_actor_user([$self->patron_id, $flesh])
976             or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
977
978     } elsif( $self->patron_barcode ) {
979
980         # note: throwing ACTOR_USER_NOT_FOUND instead of ACTOR_CARD_NOT_FOUND is intentional
981         my $card = $e->search_actor_card({barcode => $self->patron_barcode})->[0] 
982             or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
983
984         $patron = $e->retrieve_actor_user($card->usr)
985             or return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'));
986
987         # Use the card we looked up, not the patron's primary, for card active checks
988         $patron->card($card);
989
990     } else {
991         if( my $copy = $self->copy ) {
992
993             $flesh->{flesh} = 2;
994             $flesh->{flesh_fields}->{circ} = ['usr'];
995
996             my $circ = $e->search_action_circulation([
997                 {target_copy => $copy->id, checkin_time => undef}, $flesh
998             ])->[0];
999
1000             if($circ) {
1001                 $patron = $circ->usr;
1002                 $circ->usr($patron->id); # de-flesh for consistency
1003                 $self->circ($circ); 
1004             }
1005         }
1006     }
1007
1008     return $self->bail_on_events(OpenILS::Event->new('ACTOR_USER_NOT_FOUND'))
1009         unless $self->patron($patron) or $self->is_checkin;
1010
1011     unless($self->is_checkin) {
1012
1013         # Check for inactivity and patron reg. expiration
1014
1015         $self->bail_on_events(OpenILS::Event->new('PATRON_INACTIVE'))
1016             unless $U->is_true($patron->active);
1017     
1018         $self->bail_on_events(OpenILS::Event->new('PATRON_CARD_INACTIVE'))
1019             unless $U->is_true($patron->card->active);
1020     
1021         my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1022             cleanse_ISO8601($patron->expire_date));
1023     
1024         $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1025             if( CORE::time > $expire->epoch ) ;
1026     }
1027 }
1028
1029
1030 # --------------------------------------------------------------------------
1031 # Does the circ permit work
1032 # --------------------------------------------------------------------------
1033 sub do_permit {
1034     my $self = shift;
1035
1036     $self->log_me("do_permit()");
1037
1038     unless( $self->editor->requestor->id == $self->patron->id ) {
1039         return $self->bail_on_events($self->editor->event)
1040             unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1041     }
1042
1043     $self->check_captured_holds();
1044     $self->do_copy_checks();
1045     return if $self->bail_out;
1046     $self->run_patron_permit_scripts();
1047     $self->run_copy_permit_scripts() 
1048         unless $self->is_precat or $self->is_noncat;
1049     $self->check_item_deposit_events();
1050     $self->override_events();
1051     return if $self->bail_out;
1052
1053     if($self->is_precat and not $self->request_precat) {
1054         $self->push_events(
1055             OpenILS::Event->new(
1056                 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1057         return $self->bail_out(1) unless $self->is_renewal;
1058     }
1059
1060     $self->push_events(
1061         OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1062 }
1063
1064 sub check_item_deposit_events {
1065     my $self = shift;
1066     $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy)) 
1067         if $self->is_deposit and not $self->is_deposit_exempt;
1068     $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy)) 
1069         if $self->is_rental and not $self->is_rental_exempt;
1070 }
1071
1072 # returns true if the user is not required to pay deposits
1073 sub is_deposit_exempt {
1074     my $self = shift;
1075     my $pid = (ref $self->patron->profile) ?
1076         $self->patron->profile->id : $self->patron->profile;
1077     my $groups = $U->ou_ancestor_setting_value(
1078         $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1079     for my $grp (@$groups) {
1080         return 1 if $self->is_group_descendant($grp, $pid);
1081     }
1082     return 0;
1083 }
1084
1085 # returns true if the user is not required to pay rental fees
1086 sub is_rental_exempt {
1087     my $self = shift;
1088     my $pid = (ref $self->patron->profile) ?
1089         $self->patron->profile->id : $self->patron->profile;
1090     my $groups = $U->ou_ancestor_setting_value(
1091         $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1092     for my $grp (@$groups) {
1093         return 1 if $self->is_group_descendant($grp, $pid);
1094     }
1095     return 0;
1096 }
1097
1098 sub is_group_descendant {
1099     my($self, $p_id, $c_id) = @_;
1100     return 0 unless defined $p_id and defined $c_id;
1101     return 1 if $c_id == $p_id;
1102     while(my $grp = $user_groups{$c_id}) {
1103         $c_id = $grp->parent;
1104         return 0 unless defined $c_id;
1105         return 1 if $c_id == $p_id;
1106     }
1107     return 0;
1108 }
1109
1110 sub check_captured_holds {
1111     my $self    = shift;
1112     my $copy    = $self->copy;
1113     my $patron  = $self->patron;
1114
1115     return undef unless $copy;
1116
1117     my $s = $U->copy_status($copy->status)->id;
1118     return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1119     $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1120
1121     # Item is on the holds shelf, make sure it's going to the right person
1122     my $hold = $self->editor->search_action_hold_request(
1123         [
1124             { 
1125                 current_copy        => $copy->id , 
1126                 capture_time        => { '!=' => undef },
1127                 cancel_time         => undef, 
1128                 fulfillment_time    => undef 
1129             },
1130             { limit => 1,
1131               flesh => 1,
1132               flesh_fields => { ahr => ['usr'] }
1133             }
1134         ]
1135     )->[0];
1136
1137     if ($hold and $hold->usr->id == $patron->id) {
1138         $self->checkout_is_for_hold(1);
1139         return undef;
1140     } elsif ($hold) {
1141         my $payload;
1142         my $holdau = $hold->usr;
1143
1144         if ($holdau) {
1145             $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1146             $payload->{patron_id} = $holdau->id;
1147         } else {
1148             $payload->{patron_name} = "???";
1149         }
1150         $payload->{hold_id}     = $hold->id;
1151         $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1152                                                payload => $payload));
1153     }
1154
1155     $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1156
1157 }
1158
1159
1160 sub do_copy_checks {
1161     my $self = shift;
1162     my $copy = $self->copy;
1163     return unless $copy;
1164
1165     my $stat = $U->copy_status($copy->status)->id;
1166
1167     # We cannot check out a copy if it is in-transit
1168     if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1169         return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1170     }
1171
1172     $self->handle_claims_returned();
1173     return if $self->bail_out;
1174
1175     # no claims returned circ was found, check if there is any open circ
1176     unless( $self->is_renewal ) {
1177
1178         my $circs = $self->editor->search_action_circulation(
1179             { target_copy => $copy->id, checkin_time => undef }
1180         );
1181
1182         if(my $old_circ = $circs->[0]) { # an open circ was found
1183
1184             my $payload = {copy => $copy};
1185
1186             if($old_circ->usr == $self->patron->id) {
1187                 
1188                 $payload->{old_circ} = $old_circ;
1189
1190                 # If there is an open circulation on the checkout item and an auto-renew 
1191                 # interval is defined, inform the caller that they should go 
1192                 # ahead and renew the item instead of warning about open circulations.
1193     
1194                 my $auto_renew_intvl = $U->ou_ancestor_setting_value(        
1195                     $self->circ_lib,
1196                     'circ.checkout_auto_renew_age', 
1197                     $self->editor
1198                 );
1199
1200                 if($auto_renew_intvl) {
1201                     my $intvl_seconds = OpenSRF::Utils->interval_to_seconds($auto_renew_intvl);
1202                     my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( cleanse_ISO8601($old_circ->xact_start) );
1203
1204                     if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1205                         $payload->{auto_renew} = 1;
1206                     }
1207                 }
1208             }
1209
1210             return $self->bail_on_events(
1211                 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1212             );
1213         }
1214     }
1215 }
1216
1217 my $LEGACY_CIRC_EVENT_MAP = {
1218     'no_item' => 'ITEM_NOT_CATALOGED',
1219     'actor.usr.barred' => 'PATRON_BARRED',
1220     'asset.copy.circulate' =>  'COPY_CIRC_NOT_ALLOWED',
1221     'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1222     'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1223     'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1224     'config.circ_matrix_test.max_items_out' =>  'PATRON_EXCEEDS_CHECKOUT_COUNT',
1225     'config.circ_matrix_test.max_overdue' =>  'PATRON_EXCEEDS_OVERDUE_COUNT',
1226     'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1227     'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1228     'config.circ_matrix_test.total_copy_hold_ratio' => 
1229         'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1230     'config.circ_matrix_test.available_copy_hold_ratio' => 
1231         'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1232 };
1233
1234
1235 # ---------------------------------------------------------------------
1236 # This pushes any patron-related events into the list but does not
1237 # set bail_out for any events
1238 # ---------------------------------------------------------------------
1239 sub run_patron_permit_scripts {
1240     my $self        = shift;
1241     my $patronid    = $self->patron->id;
1242
1243     my @allevents; 
1244
1245
1246     my $results = $self->run_indb_circ_test;
1247     unless($self->circ_test_success) {
1248         my @trimmed_results;
1249
1250         if ($self->is_noncat) {
1251             # no_item result is OK during noncat checkout
1252             @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1253
1254         } else {
1255
1256             if ($self->checkout_is_for_hold) {
1257                 # if this checkout will fulfill a hold, ignore CIRC blocks
1258                 # and rely instead on the (later-checked) FULFILL block
1259
1260                 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1261                 my $fblock_pens = $self->editor->search_config_standing_penalty(
1262                     {name => [@pen_names], block_list => {like => '%CIRC%'}});
1263
1264                 for my $res (@$results) {
1265                     my $name = $res->{fail_part} || '';
1266                     next if grep {$_->name eq $name} @$fblock_pens;
1267                     push(@trimmed_results, $res);
1268                 }
1269
1270             } else { 
1271                 # not for hold or noncat
1272                 @trimmed_results = @$results;
1273             }
1274         }
1275
1276         # update the final set of test results
1277         $self->matrix_test_result(\@trimmed_results); 
1278
1279         push @allevents, $self->matrix_test_result_events;
1280     }
1281
1282     for (@allevents) {
1283        $_->{payload} = $self->copy if 
1284              ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1285     }
1286
1287     $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1288
1289     $self->push_events(@allevents);
1290 }
1291
1292 sub matrix_test_result_codes {
1293     my $self = shift;
1294     map { $_->{"fail_part"} } @{$self->matrix_test_result};
1295 }
1296
1297 sub matrix_test_result_events {
1298     my $self = shift;
1299     map {
1300         my $event = new OpenILS::Event(
1301             $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1302         );
1303         $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1304         $event;
1305     } (@{$self->matrix_test_result});
1306 }
1307
1308 sub run_indb_circ_test {
1309     my $self = shift;
1310     return $self->matrix_test_result if $self->matrix_test_result;
1311
1312     my $dbfunc = ($self->is_renewal) ? 
1313         'action.item_user_renew_test' : 'action.item_user_circ_test';
1314
1315     if( $self->is_precat && $self->request_precat) {
1316         $self->make_precat_copy;
1317         return if $self->bail_out;
1318     }
1319
1320     my $results = $self->editor->json_query(
1321         {   from => [
1322                 $dbfunc,
1323                 $self->circ_lib,
1324                 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id, 
1325                 $self->patron->id,
1326             ]
1327         }
1328     );
1329
1330     $self->circ_test_success($U->is_true($results->[0]->{success}));
1331
1332     if(my $mp = $results->[0]->{matchpoint}) {
1333         $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1334         $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1335         $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1336         if(defined($results->[0]->{renewals})) {
1337             $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1338         }
1339         $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1340         if(defined($results->[0]->{grace_period})) {
1341             $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1342         }
1343         $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1344         if(defined($results->[0]->{hard_due_date})) {
1345             $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1346         }
1347         # Grab the *last* response for limit_groups, where it is more likely to be filled
1348         $self->limit_groups($results->[-1]->{limit_groups});
1349     }
1350
1351     return $self->matrix_test_result($results);
1352 }
1353
1354 # ---------------------------------------------------------------------
1355 # given a use and copy, this will calculate the circulation policy
1356 # parameters.  Only works with in-db circ.
1357 # ---------------------------------------------------------------------
1358 sub do_inspect {
1359     my $self = shift;
1360
1361     return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1362
1363     $self->run_indb_circ_test;
1364
1365     my $results = {
1366         circ_test_success => $self->circ_test_success,
1367         failure_events => [],
1368         failure_codes => [],
1369         matchpoint => $self->circ_matrix_matchpoint
1370     };
1371
1372     unless($self->circ_test_success) {
1373         $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1374         $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1375     }
1376
1377     if($self->circ_matrix_matchpoint) {
1378         my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1379         my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1380         my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1381         my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1382     
1383         my $policy = $self->get_circ_policy(
1384             $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1385     
1386         $$results{$_} = $$policy{$_} for keys %$policy;
1387     }
1388
1389     return $results;
1390 }
1391
1392 # ---------------------------------------------------------------------
1393 # Loads the circ policy info for duration, recurring fine, and max
1394 # fine based on the current copy
1395 # ---------------------------------------------------------------------
1396 sub get_circ_policy {
1397     my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1398
1399     my $policy = {
1400         duration_rule => $duration_rule->name,
1401         recurring_fine_rule => $recurring_fine_rule->name,
1402         max_fine_rule => $max_fine_rule->name,
1403         max_fine => $self->get_max_fine_amount($max_fine_rule),
1404         fine_interval => $recurring_fine_rule->recurrence_interval,
1405         renewal_remaining => $duration_rule->max_renewals,
1406         auto_renewal_remaining => $duration_rule->max_auto_renewals,
1407         grace_period => $recurring_fine_rule->grace_period
1408     };
1409
1410     if($hard_due_date) {
1411         $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1412         $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1413     }
1414     else {
1415         $policy->{duration_date_ceiling} = undef;
1416         $policy->{duration_date_ceiling_force} = undef;
1417     }
1418
1419     $policy->{duration} = $duration_rule->shrt
1420         if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1421     $policy->{duration} = $duration_rule->normal
1422         if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1423     $policy->{duration} = $duration_rule->extended
1424         if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1425
1426     $policy->{recurring_fine} = $recurring_fine_rule->low
1427         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1428     $policy->{recurring_fine} = $recurring_fine_rule->normal
1429         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1430     $policy->{recurring_fine} = $recurring_fine_rule->high
1431         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1432
1433     return $policy;
1434 }
1435
1436 sub get_max_fine_amount {
1437     my $self = shift;
1438     my $max_fine_rule = shift;
1439     my $max_amount = $max_fine_rule->amount;
1440
1441     # if is_percent is true then the max->amount is
1442     # use as a percentage of the copy price
1443     if ($U->is_true($max_fine_rule->is_percent)) {
1444         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1445         $max_amount = $price * $max_fine_rule->amount / 100;
1446     } elsif (
1447         $U->ou_ancestor_setting_value(
1448             $self->circ_lib,
1449             'circ.max_fine.cap_at_price',
1450             $self->editor
1451         )
1452     ) {
1453         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1454         $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1455     }
1456
1457     return $max_amount;
1458 }
1459
1460
1461
1462 sub run_copy_permit_scripts {
1463     my $self = shift;
1464     my $copy = $self->copy || return;
1465
1466     my @allevents;
1467
1468     my $results = $self->run_indb_circ_test;
1469     push @allevents, $self->matrix_test_result_events
1470         unless $self->circ_test_success;
1471
1472     # See if this copy has an alert message
1473     my $ae = $self->check_copy_alert();
1474     push( @allevents, $ae ) if $ae;
1475
1476     # uniquify the events
1477     my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1478     @allevents = values %hash;
1479
1480     $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1481
1482     $self->push_events(@allevents);
1483 }
1484
1485
1486 sub check_copy_alert {
1487     my $self = shift;
1488
1489     if ($self->new_copy_alerts) {
1490         my @alerts;
1491         push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts 
1492             if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1493
1494         push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts 
1495             if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1496
1497         if (@alerts) {
1498             $self->bail_out(1) if (!$self->override);
1499             return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1500         }
1501     }
1502
1503     return undef if $self->is_renewal;
1504     return OpenILS::Event->new(
1505         'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1506         if $self->copy and $self->copy->alert_message;
1507     return undef;
1508 }
1509
1510
1511
1512 # --------------------------------------------------------------------------
1513 # If the call is overriding and has permissions to override every collected
1514 # event, the are cleared.  Any event that the caller does not have
1515 # permission to override, will be left in the event list and bail_out will
1516 # be set
1517 # XXX We need code in here to cancel any holds/transits on copies 
1518 # that are being force-checked out
1519 # --------------------------------------------------------------------------
1520 sub override_events {
1521     my $self = shift;
1522     my @events = @{$self->events};
1523     return unless @events;
1524     my $oargs = $self->override_args;
1525
1526     if(!$self->override) {
1527         return $self->bail_out(1) 
1528             if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1529     }   
1530
1531     $self->events([]);
1532     
1533     for my $e (@events) {
1534         my $tc = $e->{textcode};
1535         next if $tc eq 'SUCCESS';
1536         if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1537             my $ov = "$tc.override";
1538             $logger->info("circulator: attempting to override event: $ov");
1539
1540             return $self->bail_on_events($self->editor->event)
1541                 unless( $self->editor->allowed($ov) );
1542         } else {
1543             return $self->bail_out(1);
1544         }
1545    }
1546 }
1547     
1548
1549 # --------------------------------------------------------------------------
1550 # If there is an open claimsreturn circ on the requested copy, close the 
1551 # circ if overriding, otherwise bail out
1552 # --------------------------------------------------------------------------
1553 sub handle_claims_returned {
1554     my $self = shift;
1555     my $copy = $self->copy;
1556
1557     my $CR = $self->editor->search_action_circulation(
1558         {   
1559             target_copy     => $copy->id,
1560             stop_fines      => OILS_STOP_FINES_CLAIMSRETURNED,
1561             checkin_time    => undef,
1562         }
1563     );
1564
1565     return unless ($CR = $CR->[0]); 
1566
1567     my $evt;
1568
1569     # - If the caller has set the override flag, we will check the item in
1570     if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1571
1572         $CR->checkin_time('now');   
1573         $CR->checkin_scan_time('now');   
1574         $CR->checkin_lib($self->circ_lib);
1575         $CR->checkin_workstation($self->editor->requestor->wsid);
1576         $CR->checkin_staff($self->editor->requestor->id);
1577
1578         $evt = $self->editor->event 
1579             unless $self->editor->update_action_circulation($CR);
1580
1581     } else {
1582         $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1583     }
1584
1585     $self->bail_on_events($evt) if $evt;
1586     return;
1587 }
1588
1589
1590 # --------------------------------------------------------------------------
1591 # This performs the checkout
1592 # --------------------------------------------------------------------------
1593 sub do_checkout {
1594     my $self = shift;
1595
1596     $self->log_me("do_checkout()");
1597
1598     # make sure perms are good if this isn't a renewal
1599     unless( $self->is_renewal ) {
1600         return $self->bail_on_events($self->editor->event)
1601             unless( $self->editor->allowed('COPY_CHECKOUT') );
1602     }
1603
1604     # verify the permit key
1605     unless( $self->check_permit_key ) {
1606         if( $self->permit_override ) {
1607             return $self->bail_on_events($self->editor->event)
1608                 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1609         } else {
1610             return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1611         }   
1612     }
1613
1614     # if this is a non-cataloged circ, build the circ and finish
1615     if( $self->is_noncat ) {
1616         $self->checkout_noncat;
1617         $self->push_events(
1618             OpenILS::Event->new('SUCCESS', 
1619             payload => { noncat_circ => $self->circ }));
1620         return;
1621     }
1622
1623     if( $self->is_precat ) {
1624         $self->make_precat_copy;
1625         return if $self->bail_out;
1626
1627     } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1628         return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1629     }
1630
1631     $self->do_copy_checks;
1632     return if $self->bail_out;
1633
1634     $self->run_checkout_scripts();
1635     return if $self->bail_out;
1636
1637     $self->build_checkout_circ_object();
1638     return if $self->bail_out;
1639
1640     my $modify_to_start = $self->booking_adjusted_due_date();
1641     return if $self->bail_out;
1642
1643     $self->apply_modified_due_date($modify_to_start);
1644     return if $self->bail_out;
1645
1646     return $self->bail_on_events($self->editor->event)
1647         unless $self->editor->create_action_circulation($self->circ);
1648
1649     # refresh the circ to force local time zone for now
1650     $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1651
1652     if($self->limit_groups) {
1653         $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1654     }
1655
1656     $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1657     $self->update_copy;
1658     return if $self->bail_out;
1659
1660     $self->apply_deposit_fee();
1661     return if $self->bail_out;
1662
1663     $self->handle_checkout_holds();
1664     return if $self->bail_out;
1665
1666     # ------------------------------------------------------------------------------
1667     # Update the patron penalty info in the DB.  Run it for permit-overrides 
1668     # since the penalties are not updated during the permit phase
1669     # ------------------------------------------------------------------------------
1670     OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1671
1672     my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1673     
1674     my $pcirc;
1675     if($self->is_renewal) {
1676         # flesh the billing summary for the checked-in circ
1677         $pcirc = $self->editor->retrieve_action_circulation([
1678             $self->parent_circ,
1679             {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1680         ]);
1681     }
1682
1683     $self->push_events(
1684         OpenILS::Event->new('SUCCESS',
1685             payload  => {
1686                 copy             => $U->unflesh_copy($self->copy),
1687                 volume           => $self->volume,
1688                 circ             => $self->circ,
1689                 record           => $record,
1690                 holds_fulfilled  => $self->fulfilled_holds,
1691                 deposit_billing  => $self->deposit_billing,
1692                 rental_billing   => $self->rental_billing,
1693                 parent_circ      => $pcirc,
1694                 patron           => ($self->return_patron) ? $self->patron : undef,
1695                 patron_money     => $self->editor->retrieve_money_user_summary($self->patron->id)
1696             }
1697         )
1698     );
1699 }
1700
1701 sub apply_deposit_fee {
1702     my $self = shift;
1703     my $copy = $self->copy;
1704     return unless 
1705         ($self->is_deposit and not $self->is_deposit_exempt) or 
1706         ($self->is_rental and not $self->is_rental_exempt);
1707
1708     return if $self->is_deposit and $self->skip_deposit_fee;
1709     return if $self->is_rental and $self->skip_rental_fee;
1710
1711     my $bill = Fieldmapper::money::billing->new;
1712     my $amount = $copy->deposit_amount;
1713     my $billing_type;
1714     my $btype;
1715
1716     if($self->is_deposit) {
1717         $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1718         $btype = 5;
1719         $self->deposit_billing($bill);
1720     } else {
1721         $billing_type = OILS_BILLING_TYPE_RENTAL;
1722         $btype = 6;
1723         $self->rental_billing($bill);
1724     }
1725
1726     $bill->xact($self->circ->id);
1727     $bill->amount($amount);
1728     $bill->note(OILS_BILLING_NOTE_SYSTEM);
1729     $bill->billing_type($billing_type);
1730     $bill->btype($btype);
1731     $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1732
1733     $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1734 }
1735
1736 sub update_copy {
1737     my $self = shift;
1738     my $copy = $self->copy;
1739
1740     my $stat = $copy->status if ref $copy->status;
1741     my $loc = $copy->location if ref $copy->location;
1742     my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1743
1744     $copy->status($stat->id) if $stat;
1745     $copy->location($loc->id) if $loc;
1746     $copy->circ_lib($circ_lib->id) if $circ_lib;
1747     $copy->editor($self->editor->requestor->id);
1748     $copy->edit_date('now');
1749     $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1750
1751     return $self->bail_on_events($self->editor->event)
1752         unless $self->editor->update_asset_copy($self->copy);
1753
1754     $copy->status($U->copy_status($copy->status));
1755     $copy->location($loc) if $loc;
1756     $copy->circ_lib($circ_lib) if $circ_lib;
1757 }
1758
1759 sub update_reservation {
1760     my $self = shift;
1761     my $reservation = $self->reservation;
1762
1763     my $usr = $reservation->usr;
1764     my $target_rt = $reservation->target_resource_type;
1765     my $target_r = $reservation->target_resource;
1766     my $current_r = $reservation->current_resource;
1767
1768     $reservation->usr($usr->id) if ref $usr;
1769     $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1770     $reservation->target_resource($target_r->id) if ref $target_r;
1771     $reservation->current_resource($current_r->id) if ref $current_r;
1772
1773     return $self->bail_on_events($self->editor->event)
1774         unless $self->editor->update_booking_reservation($self->reservation);
1775
1776     my $evt;
1777     ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1778     $self->reservation($reservation);
1779 }
1780
1781
1782 sub bail_on_events {
1783     my( $self, @evts ) = @_;
1784     $self->push_events(@evts);
1785     $self->bail_out(1);
1786 }
1787
1788 # ------------------------------------------------------------------------------
1789 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1790 # affects copies that will fulfill holds and CIRC affects all other copies.
1791 # If blocks exists, bail, push Events onto the event pile, and return true.
1792 # ------------------------------------------------------------------------------
1793 sub check_hold_fulfill_blocks {
1794     my $self = shift;
1795
1796     # With the addition of ignore_proximity in csp, we need to fetch
1797     # the proximity of both the circ_lib and the copy's circ_lib to
1798     # the patron's home_ou.
1799     my ($ou_prox, $copy_prox);
1800     my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1801     $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1802     $ou_prox = -1 unless (defined($ou_prox));
1803     my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1804     if ($copy_ou == $self->circ_lib) {
1805         # Save us the time of an extra query.
1806         $copy_prox = $ou_prox;
1807     } else {
1808         $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1809         $copy_prox = -1 unless (defined($copy_prox));
1810     }
1811
1812     # See if the user has any penalties applied that prevent hold fulfillment
1813     my $pens = $self->editor->json_query({
1814         select => {csp => ['name', 'label']},
1815         from => {ausp => {csp => {}}},
1816         where => {
1817             '+ausp' => {
1818                 usr => $self->patron->id,
1819                 org_unit => $U->get_org_full_path($self->circ_lib),
1820                 '-or' => [
1821                     {stop_date => undef},
1822                     {stop_date => {'>' => 'now'}}
1823                 ]
1824             },
1825             '+csp' => {
1826                 block_list => {'like' => '%FULFILL%'},
1827                 '-or' => [
1828                     {ignore_proximity => undef},
1829                     {ignore_proximity => {'<' => $ou_prox}},
1830                     {ignore_proximity => {'<' => $copy_prox}}
1831                 ]
1832             }
1833         }
1834     });
1835
1836     return 0 unless @$pens;
1837
1838     for my $pen (@$pens) {
1839         $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1840         my $event = OpenILS::Event->new($pen->{name});
1841         $event->{desc} = $pen->{label};
1842         $self->push_events($event);
1843     }
1844
1845     $self->override_events;
1846     return $self->bail_out;
1847 }
1848
1849
1850 # ------------------------------------------------------------------------------
1851 # When an item is checked out, see if we can fulfill a hold for this patron
1852 # ------------------------------------------------------------------------------
1853 sub handle_checkout_holds {
1854    my $self    = shift;
1855    my $copy    = $self->copy;
1856    my $patron  = $self->patron;
1857
1858    my $e = $self->editor;
1859    $self->fulfilled_holds([]);
1860
1861    # non-cats can't fulfill a hold
1862    return if $self->is_noncat;
1863
1864     my $hold = $e->search_action_hold_request({   
1865         current_copy        => $copy->id , 
1866         cancel_time         => undef, 
1867         fulfillment_time    => undef
1868     })->[0];
1869
1870     if($hold and $hold->usr != $patron->id) {
1871         # reset the hold since the copy is now checked out
1872     
1873         $logger->info("circulator: un-targeting hold ".$hold->id.
1874             " because copy ".$copy->id." is getting checked out");
1875
1876         $hold->clear_prev_check_time; 
1877         $hold->clear_current_copy;
1878         $hold->clear_capture_time;
1879         $hold->clear_shelf_time;
1880         $hold->clear_shelf_expire_time;
1881         $hold->clear_current_shelf_lib;
1882
1883         return $self->bail_on_event($e->event)
1884             unless $e->update_action_hold_request($hold);
1885
1886         $hold = undef;
1887     }
1888
1889     unless($hold) {
1890         $hold = $self->find_related_user_hold($copy, $patron) or return;
1891         $logger->info("circulator: found related hold to fulfill in checkout");
1892     }
1893
1894     return if $self->check_hold_fulfill_blocks;
1895
1896     $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1897
1898     # if the hold was never officially captured, capture it.
1899     $hold->current_copy($copy->id);
1900     $hold->capture_time('now') unless $hold->capture_time;
1901     $hold->fulfillment_time('now');
1902     $hold->fulfillment_staff($e->requestor->id);
1903     $hold->fulfillment_lib($self->circ_lib);
1904
1905     return $self->bail_on_events($e->event)
1906         unless $e->update_action_hold_request($hold);
1907
1908     return $self->fulfilled_holds([$hold->id]);
1909 }
1910
1911
1912 # ------------------------------------------------------------------------------
1913 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1914 # the patron directly targets the checked out item, see if there is another hold 
1915 # for the patron that could be fulfilled by the checked out item.  Fulfill the
1916 # oldest hold and only fulfill 1 of them.
1917
1918 # For "another hold":
1919 #
1920 # First, check for one that the copy matches via hold_copy_map, ensuring that
1921 # *any* hold type that this copy could fill may end up filled.
1922 #
1923 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1924 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1925 # that are non-requestable to count as capturing those hold types.
1926 # ------------------------------------------------------------------------------
1927 sub find_related_user_hold {
1928     my($self, $copy, $patron) = @_;
1929     my $e = $self->editor;
1930
1931     # holds on precat copies are always copy-level, so this call will
1932     # always return undef.  Exit early.
1933     return undef if $self->is_precat;
1934
1935     return undef unless $U->ou_ancestor_setting_value(        
1936         $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1937
1938     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1939     my $args = {
1940         select => {ahr => ['id']}, 
1941         from => {
1942             ahr => {
1943                 ahcm => {
1944                     field => 'hold',
1945                     fkey => 'id'
1946                 },
1947                 acp => {
1948                     field => 'id', 
1949                     fkey => 'current_copy',
1950                     type => 'left' # there may be no current_copy
1951                 }
1952             }
1953         }, 
1954         where => {
1955             '+ahr' => {
1956                 usr => $patron->id,
1957                 fulfillment_time => undef,
1958                 cancel_time => undef,
1959                '-or' => [
1960                     {expire_time => undef},
1961                     {expire_time => {'>' => 'now'}}
1962                 ]
1963             },
1964             '+ahcm' => {
1965                 target_copy => $self->copy->id
1966             },
1967             '+acp' => {
1968                 '-or' => [
1969                     {id => undef}, # left-join copy may be nonexistent
1970                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1971                 ]
1972             }
1973         },
1974         order_by => {ahr => {request_time => {direction => 'asc'}}},
1975         limit => 1
1976     };
1977
1978     my $hold_info = $e->json_query($args)->[0];
1979     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1980     return undef if $U->ou_ancestor_setting_value(        
1981         $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1982
1983     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1984     $args = {
1985         select => {ahr => ['id']}, 
1986         from => {
1987             ahr => {
1988                 acp => {
1989                     field => 'id', 
1990                     fkey => 'current_copy',
1991                     type => 'left' # there may be no current_copy
1992                 }
1993             }
1994         }, 
1995         where => {
1996             '+ahr' => {
1997                 usr => $patron->id,
1998                 fulfillment_time => undef,
1999                 cancel_time => undef,
2000                '-or' => [
2001                     {expire_time => undef},
2002                     {expire_time => {'>' => 'now'}}
2003                 ]
2004             },
2005             '-or' => [
2006                 {
2007                     '+ahr' => { 
2008                         hold_type => 'V',
2009                         target => $self->volume->id
2010                     }
2011                 },
2012                 { 
2013                     '+ahr' => { 
2014                         hold_type => 'T',
2015                         target => $self->title->id
2016                     }
2017                 },
2018             ],
2019             '+acp' => {
2020                 '-or' => [
2021                     {id => undef}, # left-join copy may be nonexistent
2022                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2023                 ]
2024             }
2025         },
2026         order_by => {ahr => {request_time => {direction => 'asc'}}},
2027         limit => 1
2028     };
2029
2030     $hold_info = $e->json_query($args)->[0];
2031     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2032     return undef;
2033 }
2034
2035
2036 sub run_checkout_scripts {
2037     my $self = shift;
2038     my $nobail = shift;
2039
2040     my $evt;
2041
2042     my $duration;
2043     my $recurring;
2044     my $max_fine;
2045     my $hard_due_date;
2046     my $duration_name;
2047     my $recurring_name;
2048     my $max_fine_name;
2049     my $hard_due_date_name;
2050
2051     $self->run_indb_circ_test();
2052     $duration = $self->circ_matrix_matchpoint->duration_rule;
2053     $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2054     $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2055     $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2056
2057     $duration_name = $duration->name if $duration;
2058     if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2059
2060         unless($duration) {
2061             ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2062             return $self->bail_on_events($evt) if ($evt && !$nobail);
2063         
2064             ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2065             return $self->bail_on_events($evt) if ($evt && !$nobail);
2066         
2067             ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2068             return $self->bail_on_events($evt) if ($evt && !$nobail);
2069
2070             if($hard_due_date_name) {
2071                 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2072                 return $self->bail_on_events($evt) if ($evt && !$nobail);
2073             }
2074         }
2075
2076     } else {
2077
2078         # The item circulates with an unlimited duration
2079         $duration   = undef;
2080         $recurring  = undef;
2081         $max_fine   = undef;
2082         $hard_due_date = undef;
2083     }
2084
2085    $self->duration_rule($duration);
2086    $self->recurring_fines_rule($recurring);
2087    $self->max_fine_rule($max_fine);
2088    $self->hard_due_date($hard_due_date);
2089 }
2090
2091
2092 sub build_checkout_circ_object {
2093     my $self = shift;
2094
2095    my $circ       = Fieldmapper::action::circulation->new;
2096    my $duration   = $self->duration_rule;
2097    my $max        = $self->max_fine_rule;
2098    my $recurring  = $self->recurring_fines_rule;
2099    my $hard_due_date    = $self->hard_due_date;
2100    my $copy       = $self->copy;
2101    my $patron     = $self->patron;
2102    my $duration_date_ceiling;
2103    my $duration_date_ceiling_force;
2104
2105     if( $duration ) {
2106
2107         my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2108         $duration_date_ceiling = $policy->{duration_date_ceiling};
2109         $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2110
2111         my $dname = $duration->name;
2112         my $mname = $max->name;
2113         my $rname = $recurring->name;
2114         my $hdname = ''; 
2115         if($hard_due_date) {
2116             $hdname = $hard_due_date->name;
2117         }
2118
2119         $logger->debug("circulator: building circulation ".
2120             "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2121     
2122         $circ->duration($policy->{duration});
2123         $circ->recurring_fine($policy->{recurring_fine});
2124         $circ->duration_rule($duration->name);
2125         $circ->recurring_fine_rule($recurring->name);
2126         $circ->max_fine_rule($max->name);
2127         $circ->max_fine($policy->{max_fine});
2128         $circ->fine_interval($recurring->recurrence_interval);
2129         $circ->renewal_remaining($duration->max_renewals);
2130         $circ->auto_renewal_remaining($duration->max_auto_renewals);
2131         $circ->grace_period($policy->{grace_period});
2132
2133     } else {
2134
2135         $logger->info("circulator: copy found with an unlimited circ duration");
2136         $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2137         $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2138         $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2139         $circ->renewal_remaining(0);
2140         $circ->grace_period(0);
2141     }
2142
2143    $circ->target_copy( $copy->id );
2144    $circ->usr( $patron->id );
2145    $circ->circ_lib( $self->circ_lib );
2146    $circ->workstation($self->editor->requestor->wsid) 
2147     if defined $self->editor->requestor->wsid;
2148
2149     # renewals maintain a link to the parent circulation
2150     $circ->parent_circ($self->parent_circ);
2151
2152    if( $self->is_renewal ) {
2153       $circ->opac_renewal('t') if $self->opac_renewal;
2154       $circ->phone_renewal('t') if $self->phone_renewal;
2155       $circ->desk_renewal('t') if $self->desk_renewal;
2156       $circ->renewal_remaining($self->renewal_remaining);
2157       $circ->circ_staff($self->editor->requestor->id);
2158    }
2159
2160    if ( $self->is_autorenewal ){
2161       $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2162       $circ->auto_renewal('t');
2163    }
2164
2165     # if the user provided an overiding checkout time,
2166     # (e.g. the checkout really happened several hours ago), then
2167     # we apply that here.  Does this need a perm??
2168     $circ->xact_start(cleanse_ISO8601($self->checkout_time))
2169         if $self->checkout_time;
2170
2171     # if a patron is renewing, 'requestor' will be the patron
2172     $circ->circ_staff($self->editor->requestor->id);
2173     $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2174
2175     $self->circ($circ);
2176 }
2177
2178 sub do_reservation_pickup {
2179     my $self = shift;
2180
2181     $self->log_me("do_reservation_pickup()");
2182
2183     $self->reservation->pickup_time('now');
2184
2185     if (
2186         $self->reservation->current_resource &&
2187         $U->is_true($self->reservation->target_resource_type->catalog_item)
2188     ) {
2189         # We used to try to set $self->copy and $self->patron here,
2190         # but that should already be done.
2191
2192         $self->run_checkout_scripts(1);
2193
2194         my $duration   = $self->duration_rule;
2195         my $max        = $self->max_fine_rule;
2196         my $recurring  = $self->recurring_fines_rule;
2197
2198         if ($duration && $max && $recurring) {
2199             my $policy = $self->get_circ_policy($duration, $recurring, $max);
2200
2201             my $dname = $duration->name;
2202             my $mname = $max->name;
2203             my $rname = $recurring->name;
2204
2205             $logger->debug("circulator: updating reservation ".
2206                 "with duration=$dname, maxfine=$mname, recurring=$rname");
2207
2208             $self->reservation->fine_amount($policy->{recurring_fine});
2209             $self->reservation->max_fine($policy->{max_fine});
2210             $self->reservation->fine_interval($recurring->recurrence_interval);
2211         }
2212
2213         $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2214         $self->update_copy();
2215
2216     } else {
2217         $self->reservation->fine_amount(
2218             $self->reservation->target_resource_type->fine_amount
2219         );
2220         $self->reservation->max_fine(
2221             $self->reservation->target_resource_type->max_fine
2222         );
2223         $self->reservation->fine_interval(
2224             $self->reservation->target_resource_type->fine_interval
2225         );
2226     }
2227
2228     $self->update_reservation();
2229 }
2230
2231 sub do_reservation_return {
2232     my $self = shift;
2233     my $request = shift;
2234
2235     $self->log_me("do_reservation_return()");
2236
2237     if (not ref $self->reservation) {
2238         my ($reservation, $evt) =
2239             $U->fetch_booking_reservation($self->reservation);
2240         return $self->bail_on_events($evt) if $evt;
2241         $self->reservation($reservation);
2242     }
2243
2244     $self->handle_fines(1);
2245     $self->reservation->return_time('now');
2246     $self->update_reservation();
2247     $self->reshelve_copy if $self->copy;
2248
2249     if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2250         $self->copy( $self->reservation->current_resource->catalog_item );
2251     }
2252 }
2253
2254 sub booking_adjusted_due_date {
2255     my $self = shift;
2256     my $circ = $self->circ;
2257     my $copy = $self->copy;
2258
2259     return undef unless $self->use_booking;
2260
2261     my $changed;
2262
2263     if( $self->due_date ) {
2264
2265         return $self->bail_on_events($self->editor->event)
2266             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2267
2268        $circ->due_date(cleanse_ISO8601($self->due_date));
2269
2270     } else {
2271
2272         return unless $copy and $circ->due_date;
2273     }
2274
2275     my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2276     if (@$booking_items) {
2277         my $booking_item = $booking_items->[0];
2278         my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2279
2280         my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2281         my $shorten_circ_setting = $resource_type->elbow_room ||
2282             $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2283             '0 seconds';
2284
2285         my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2286         my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2287               resource     => $booking_item->id
2288             , search_start => 'now'
2289             , search_end   => $circ->due_date
2290             , fields       => { cancel_time => undef, return_time => undef }
2291         })->gather(1);
2292         $booking_ses->disconnect;
2293
2294         throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2295         return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2296         
2297         my $dt_parser = DateTime::Format::ISO8601->new;
2298         my $due_date = $dt_parser->parse_datetime( cleanse_ISO8601($circ->due_date) );
2299
2300         for my $bid (@$bookings) {
2301
2302             my $booking = $self->editor->retrieve_booking_reservation( $bid );
2303
2304             my $booking_start = $dt_parser->parse_datetime( cleanse_ISO8601($booking->start_time) );
2305             my $booking_end = $dt_parser->parse_datetime( cleanse_ISO8601($booking->end_time) );
2306
2307             return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2308                 if ($booking_start < DateTime->now);
2309
2310
2311             if ($U->is_true($stop_circ_setting)) {
2312                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ); 
2313             } else {
2314                 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2315                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now); 
2316             }
2317             
2318             # We set the circ duration here only to affect the logic that will
2319             # later (in a DB trigger) mangle the time part of the due date to
2320             # 11:59pm. Having any circ duration that is not a whole number of
2321             # days is enough to prevent the "correction."
2322             my $new_circ_duration = $due_date->epoch - time;
2323             $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2324             $circ->duration("$new_circ_duration seconds");
2325
2326             $circ->due_date(cleanse_ISO8601($due_date->strftime('%FT%T%z')));
2327             $changed = 1;
2328         }
2329
2330         return $self->bail_on_events($self->editor->event)
2331             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2332     }
2333
2334     return $changed;
2335 }
2336
2337 sub apply_modified_due_date {
2338     my $self = shift;
2339     my $shift_earlier = shift;
2340     my $circ = $self->circ;
2341     my $copy = $self->copy;
2342
2343    if( $self->due_date ) {
2344
2345         return $self->bail_on_events($self->editor->event)
2346             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2347
2348       $circ->due_date(cleanse_ISO8601($self->due_date));
2349
2350    } else {
2351
2352       # if the due_date lands on a day when the location is closed
2353       return unless $copy and $circ->due_date;
2354
2355         #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2356
2357         # due-date overlap should be determined by the location the item
2358         # is checked out from, not the owning or circ lib of the item
2359         my $org = $self->circ_lib;
2360
2361       $logger->info("circulator: circ searching for closed date overlap on lib $org".
2362             " with an item due date of ".$circ->due_date );
2363
2364       my $dateinfo = $U->storagereq(
2365          'open-ils.storage.actor.org_unit.closed_date.overlap', 
2366             $org, $circ->due_date );
2367
2368       if($dateinfo) {
2369          $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2370             $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2371
2372             # XXX make the behavior more dynamic
2373             # for now, we just push the due date to after the close date
2374             if ($shift_earlier) {
2375                 $circ->due_date($dateinfo->{start});
2376             } else {
2377                 $circ->due_date($dateinfo->{end});
2378             }
2379       }
2380    }
2381 }
2382
2383
2384
2385 sub create_due_date {
2386     my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2387
2388     # if there is a raw time component (e.g. from postgres), 
2389     # turn it into an interval that interval_to_seconds can parse
2390     $duration =~ s/(\d{2}):(\d{2}):(\d{2})/$1 h $2 m $3 s/o;
2391
2392     # for now, use the server timezone.  TODO: use workstation org timezone
2393     my $due_date = DateTime->now(time_zone => 'local');
2394     $due_date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($start_time)) if $start_time;
2395
2396     # add the circ duration
2397     $due_date->add(seconds => OpenSRF::Utils->interval_to_seconds($duration));
2398
2399     if($date_ceiling) {
2400         my $cdate = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date_ceiling));
2401         if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2402             $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2403             $due_date = $cdate;
2404         }
2405     }
2406
2407     # return ISO8601 time with timezone
2408     return $due_date->strftime('%FT%T%z');
2409 }
2410
2411
2412
2413 sub make_precat_copy {
2414     my $self = shift;
2415     my $copy = $self->copy;
2416
2417    if($copy) {
2418         $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2419
2420         $copy->editor($self->editor->requestor->id);
2421         $copy->edit_date('now');
2422         $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2423         $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2424         $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2425         $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2426         $self->update_copy();
2427         return;
2428    }
2429
2430     $logger->info("circulator: Creating a new precataloged ".
2431         "copy in checkout with barcode " . $self->copy_barcode);
2432
2433     $copy = Fieldmapper::asset::copy->new;
2434     $copy->circ_lib($self->circ_lib);
2435     $copy->creator($self->editor->requestor->id);
2436     $copy->editor($self->editor->requestor->id);
2437     $copy->barcode($self->copy_barcode);
2438     $copy->call_number(OILS_PRECAT_CALL_NUMBER); 
2439     $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2440     $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2441
2442     $copy->dummy_title($self->dummy_title || "");
2443     $copy->dummy_author($self->dummy_author || "");
2444     $copy->dummy_isbn($self->dummy_isbn || "");
2445     $copy->circ_modifier($self->circ_modifier);
2446
2447
2448     # See if we need to override the circ_lib for the copy with a configured circ_lib
2449     # Setting is shortname of the org unit
2450     my $precat_circ_lib = $U->ou_ancestor_setting_value(
2451         $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2452
2453     if($precat_circ_lib) {
2454         my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2455
2456         if(!$org) {
2457             $self->bail_on_events($self->editor->event);
2458             return;
2459         }
2460
2461         $copy->circ_lib($org->id);
2462     }
2463
2464
2465     unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2466         $self->bail_out(1);
2467         $self->push_events($self->editor->event);
2468         return;
2469     }   
2470 }
2471
2472
2473 sub checkout_noncat {
2474     my $self = shift;
2475
2476     my $circ;
2477     my $evt;
2478
2479    my $lib      = $self->noncat_circ_lib || $self->circ_lib;
2480    my $count    = $self->noncat_count || 1;
2481    my $cotime   = cleanse_ISO8601($self->checkout_time) || "";
2482
2483    $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2484
2485    for(1..$count) {
2486
2487       ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2488          $self->editor->requestor->id, 
2489             $self->patron->id, 
2490             $lib, 
2491             $self->noncat_type, 
2492             $cotime,
2493             $self->editor );
2494
2495         if( $evt ) {
2496             $self->push_events($evt);
2497             $self->bail_out(1);
2498             return; 
2499         }
2500         $self->circ($circ);
2501    }
2502 }
2503
2504 # If a copy goes into transit and is then checked in before the transit checkin 
2505 # interval has expired, push an event onto the overridable events list.
2506 sub check_transit_checkin_interval {
2507     my $self = shift;
2508
2509     # only concerned with in-transit items
2510     return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2511
2512     # no interval, no problem
2513     my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2514     return unless $interval;
2515
2516     # capture the transit so we don't have to fetch it again later during checkin
2517     $self->transit(
2518         $self->editor->search_action_transit_copy(
2519             {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2520         )->[0]
2521     ); 
2522
2523     # transit from X to X for whatever reason has no min interval
2524     return if $self->transit->source == $self->transit->dest;
2525
2526     my $seconds = OpenSRF::Utils->interval_to_seconds($interval);
2527     my $t_start = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($self->transit->source_send_time));
2528     my $horizon = $t_start->add(seconds => $seconds);
2529
2530     # See if we are still within the transit checkin forbidden range
2531     $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK')) 
2532         if $horizon > DateTime->now;
2533 }
2534
2535 # Retarget local holds at checkin
2536 sub checkin_retarget {
2537     my $self = shift;
2538     return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2539     return unless $self->is_checkin; # Renewals need not be checked
2540     return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2541     return if $self->is_precat; # No holds for precats
2542     return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2543     return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2544     my $status = $U->copy_status($self->copy->status);
2545     return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2546     # Specifically target items that are likely new (by status ID)
2547     return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2548     my $location = $self->copy->location;
2549     if(!ref($location)) {
2550         $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2551         $self->copy->location($location);
2552     }
2553     return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2554
2555     # Fetch holds for the bib
2556     my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2557                     $self->editor->authtoken,
2558                     $self->title->id,
2559                     {
2560                         capture_time => undef, # No touching captured holds
2561                         frozen => 'f', # Don't bother with frozen holds
2562                         pickup_lib => $self->circ_lib # Only holds actually here
2563                     }); 
2564
2565     # Error? Skip the step.
2566     return if exists $result->{"ilsevent"};
2567
2568     # Assemble holds
2569     my $holds = [];
2570     foreach my $holdlist (keys %{$result}) {
2571         push @$holds, @{$result->{$holdlist}};
2572     }
2573
2574     return if scalar(@$holds) == 0; # No holds, no retargeting
2575
2576     # Check for parts on this copy
2577     my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2578     my %parts_hash = ();
2579     %parts_hash = map {$_->part, 1} @$parts if @$parts;
2580
2581     # Loop over holds in request-ish order
2582     # Stage 1: Get them into request-ish order
2583     # Also grab type and target for skipping low hanging ones
2584     $result = $self->editor->json_query({
2585         "select" => { "ahr" => ["id", "hold_type", "target"] },
2586         "from" => { "ahr" => { "au" => { "fkey" => "usr",  "join" => "pgt"} } },
2587         "where" => { "id" => $holds },
2588         "order_by" => [
2589             { "class" => "pgt", "field" => "hold_priority"},
2590             { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2591             { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2592             { "class" => "ahr", "field" => "request_time"}
2593         ]
2594     });
2595
2596     # Stage 2: Loop!
2597     if (ref $result eq "ARRAY" and scalar @$result) {
2598         foreach (@{$result}) {
2599             # Copy level, but not this copy?
2600             next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2601                 and $_->{target} != $self->copy->id);
2602             # Volume level, but not this volume?
2603             next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2604             if(@$parts) { # We have parts?
2605                 # Skip title holds
2606                 next if ($_->{hold_type} eq 'T');
2607                 # Skip part holds for parts not on this copy
2608                 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2609             } else {
2610                 # No parts, no part holds
2611                 next if ($_->{hold_type} eq 'P');
2612             }
2613             # So much for easy stuff, attempt a retarget!
2614             my $tresult = $U->simplereq(
2615                 'open-ils.hold-targeter',
2616                 'open-ils.hold-targeter.target', 
2617                 {hold => $_->{id}, find_copy => $self->copy->id}
2618             );
2619             if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2620                 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2621             }
2622         }
2623     }
2624 }
2625
2626 sub do_checkin {
2627     my $self = shift;
2628     $self->log_me("do_checkin()");
2629
2630     return $self->bail_on_events(
2631         OpenILS::Event->new('ASSET_COPY_NOT_FOUND')) 
2632         unless $self->copy;
2633
2634     $self->check_transit_checkin_interval;
2635     $self->checkin_retarget;
2636
2637     # the renew code and mk_env should have already found our circulation object
2638     unless( $self->circ ) {
2639
2640         my $circs = $self->editor->search_action_circulation(
2641             { target_copy => $self->copy->id, checkin_time => undef });
2642
2643         $self->circ($$circs[0]);
2644
2645         # for now, just warn if there are multiple open circs on a copy
2646         $logger->warn("circulator: we have ".scalar(@$circs).
2647             " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2648     }
2649
2650     my $stat = $U->copy_status($self->copy->status)->id;
2651
2652     # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2653     # differently if they are already paid for.  We need to check for this
2654     # early since overdue generation is potentially affected.
2655     my $dont_change_lost_zero = 0;
2656     if ($stat == OILS_COPY_STATUS_LOST
2657         || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2658         || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2659
2660         # LOST fine settings are controlled by the copy's circ lib, not the the
2661         # circulation's
2662         my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2663                 $self->copy->circ_lib->id : $self->copy->circ_lib;
2664         $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2665             $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2666             $self->editor) || 0;
2667
2668         if ($dont_change_lost_zero) {
2669             my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2670             $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2671         }
2672
2673         $self->dont_change_lost_zero($dont_change_lost_zero);
2674     }
2675
2676     if( $self->checkin_check_holds_shelf() ) {
2677         $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2678         $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2679         if($self->fake_hold_dest) {
2680             $self->hold->pickup_lib($self->circ_lib);
2681         }
2682         $self->checkin_flesh_events;
2683         return;
2684     }
2685
2686     unless( $self->is_renewal ) {
2687         return $self->bail_on_events($self->editor->event)
2688             unless $self->editor->allowed('COPY_CHECKIN');
2689     }
2690
2691     $self->push_events($self->check_copy_alert());
2692     $self->push_events($self->check_checkin_copy_status());
2693
2694     # if the circ is marked as 'claims returned', add the event to the list
2695     $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2696         if ($self->circ and $self->circ->stop_fines 
2697                 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2698
2699     $self->check_circ_deposit();
2700
2701     # handle the overridable events 
2702     $self->override_events unless $self->is_renewal;
2703     return if $self->bail_out;
2704     
2705     if( $self->copy and !$self->transit ) {
2706         $self->transit(
2707             $self->editor->search_action_transit_copy(
2708                 { target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef }
2709             )->[0]
2710         ); 
2711     }
2712
2713     if( $self->circ ) {
2714         $self->checkin_handle_circ_start;
2715         return if $self->bail_out;
2716
2717         if (!$dont_change_lost_zero) {
2718             # if this circ is LOST and we are configured to generate overdue
2719             # fines for lost items on checkin (to fill the gap between mark
2720             # lost time and when the fines would have naturally stopped), then
2721             # stop_fines is no longer valid and should be cleared.
2722             #
2723             # stop_fines will be set again during the handle_fines() stage.
2724             # XXX should this setting come from the copy circ lib (like other
2725             # LOST settings), instead of the circulation circ lib?
2726             if ($stat == OILS_COPY_STATUS_LOST) {
2727                 $self->circ->clear_stop_fines if
2728                     $U->ou_ancestor_setting_value(
2729                         $self->circ_lib,
2730                         OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2731                         $self->editor
2732                     );
2733             }
2734
2735             # Set stop_fines when claimed never checked out
2736             $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2737
2738             # handle fines for this circ, including overdue gen if needed
2739             $self->handle_fines;
2740         }
2741
2742         $self->checkin_handle_circ_finish;
2743         return if $self->bail_out;
2744         $self->checkin_changed(1);
2745
2746     } elsif( $self->transit ) {
2747         my $hold_transit = $self->process_received_transit;
2748         $self->checkin_changed(1);
2749
2750         if( $self->bail_out ) { 
2751             $self->checkin_flesh_events;
2752             return;
2753         }
2754         
2755         if( my $e = $self->check_checkin_copy_status() ) {
2756             # If the original copy status is special, alert the caller
2757             my $ev = $self->events;
2758             $self->events([$e]);
2759             $self->override_events;
2760             return if $self->bail_out;
2761             $self->events($ev);
2762         }
2763
2764         if( $hold_transit or 
2765                 $U->copy_status($self->copy->status)->id 
2766                     == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2767
2768             my $hold;
2769             if( $hold_transit ) {
2770                $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2771             } else {
2772                    ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2773             }
2774
2775             $self->hold($hold);
2776
2777             if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2778
2779                 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2780                 $self->reshelve_copy(1);
2781                 $self->cancelled_hold_transit(1);
2782                 $self->notify_hold(0); # don't notify for cancelled holds
2783                 $self->fake_hold_dest(0);
2784                 return if $self->bail_out;
2785
2786             } elsif ($hold and $hold->hold_type eq 'R') {
2787
2788                 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2789                 $self->notify_hold(0); # No need to notify
2790                 $self->fake_hold_dest(0);
2791                 $self->noop(1); # Don't try and capture for other holds/transits now
2792                 $self->update_copy();
2793                 $hold->fulfillment_time('now');
2794                 $self->bail_on_events($self->editor->event)
2795                     unless $self->editor->update_action_hold_request($hold);
2796
2797             } else {
2798
2799                 # hold transited to correct location
2800                 if($self->fake_hold_dest) {
2801                     $hold->pickup_lib($self->circ_lib);
2802                 }
2803                 $self->checkin_flesh_events;
2804                 return;
2805             }
2806         } 
2807
2808     } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2809
2810         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2811             " that is in-transit, but there is no transit.. repairing");
2812         $self->reshelve_copy(1);
2813         return if $self->bail_out;
2814     }
2815
2816     if( $self->is_renewal ) {
2817         $self->finish_fines_and_voiding;
2818         return if $self->bail_out;
2819         $self->push_events(OpenILS::Event->new('SUCCESS'));
2820         return;
2821     }
2822
2823    # ------------------------------------------------------------------------------
2824    # Circulations and transits are now closed where necessary.  Now go on to see if
2825    # this copy can fulfill a hold or needs to be routed to a different location
2826    # ------------------------------------------------------------------------------
2827
2828     my $needed_for_something = 0; # formerly "needed_for_hold"
2829
2830     if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2831
2832         if (!$self->remote_hold) {
2833             if ($self->use_booking) {
2834                 my $potential_hold = $self->hold_capture_is_possible;
2835                 my $potential_reservation = $self->reservation_capture_is_possible;
2836
2837                 if ($potential_hold and $potential_reservation) {
2838                     $logger->info("circulator: item could fulfill either hold or reservation");
2839                     $self->push_events(new OpenILS::Event(
2840                         "HOLD_RESERVATION_CONFLICT",
2841                         "hold" => $potential_hold,
2842                         "reservation" => $potential_reservation
2843                     ));
2844                     return if $self->bail_out;
2845                 } elsif ($potential_hold) {
2846                     $needed_for_something =
2847                         $self->attempt_checkin_hold_capture;
2848                 } elsif ($potential_reservation) {
2849                     $needed_for_something =
2850                         $self->attempt_checkin_reservation_capture;
2851                 }
2852             } else {
2853                 $needed_for_something = $self->attempt_checkin_hold_capture;
2854             }
2855         }
2856         return if $self->bail_out;
2857     
2858         unless($needed_for_something) {
2859             my $circ_lib = (ref $self->copy->circ_lib) ? 
2860                     $self->copy->circ_lib->id : $self->copy->circ_lib;
2861     
2862             if( $self->remote_hold ) {
2863                 $circ_lib = $self->remote_hold->pickup_lib;
2864                 $logger->warn("circulator: Copy ".$self->copy->barcode.
2865                     " is on a remote hold's shelf, sending to $circ_lib");
2866             }
2867     
2868             $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2869
2870             my $suppress_transit = 0;
2871
2872             if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2873                 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2874                 if($suppress_transit_source && $suppress_transit_source->{value}) {
2875                     my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2876                     if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2877                         $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2878                         $suppress_transit = 1;
2879                     }
2880                 }
2881             }
2882  
2883             if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2884                 # copy is where it needs to be, either for hold or reshelving
2885     
2886                 $self->checkin_handle_precat();
2887                 return if $self->bail_out;
2888     
2889             } else {
2890                 # copy needs to transit "home", or stick here if it's a floating copy
2891                 my $can_float = 0;
2892                 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2893                     my $res = $self->editor->json_query(
2894                         {   from => [
2895                                 'evergreen.can_float',
2896                                 $self->copy->floating->id,
2897                                 $self->copy->circ_lib,
2898                                 $self->circ_lib
2899                             ]
2900                         }
2901                     );
2902                     $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res; 
2903                 }
2904                 if ($can_float) { # Yep, floating, stick here
2905                     $self->checkin_changed(1);
2906                     $self->copy->circ_lib( $self->circ_lib );
2907                     $self->update_copy;
2908                 } else {
2909                     my $bc = $self->copy->barcode;
2910                     $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2911                     $self->checkin_build_copy_transit($circ_lib);
2912                     return if $self->bail_out;
2913                     $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2914                 }
2915             }
2916         }
2917     } else { # no-op checkin
2918         if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2919             my $res = $self->editor->json_query(
2920                 {
2921                     from => [
2922                         'evergreen.can_float',
2923                         $self->copy->floating->id,
2924                         $self->copy->circ_lib,
2925                         $self->circ_lib
2926                     ]
2927                 }
2928             );
2929             if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2930                 $self->checkin_changed(1);
2931                 $self->copy->circ_lib( $self->circ_lib );
2932                 $self->update_copy;
2933             }
2934         }
2935     }
2936
2937     if($self->claims_never_checked_out and 
2938             $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2939
2940         # the item was not supposed to be checked out to the user and should now be marked as missing
2941         my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
2942         $self->copy->status($next_status);
2943         $self->update_copy;
2944
2945     } else {
2946         $self->reshelve_copy unless $needed_for_something;
2947     }
2948
2949     return if $self->bail_out;
2950
2951     unless($self->checkin_changed) {
2952
2953         $self->push_events(OpenILS::Event->new('NO_CHANGE'));
2954         my $stat = $U->copy_status($self->copy->status)->id;
2955
2956         $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
2957          if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
2958         $self->bail_out(1); # no need to commit anything
2959
2960     } else {
2961
2962         $self->push_events(OpenILS::Event->new('SUCCESS')) 
2963             unless @{$self->events};
2964     }
2965
2966     $self->finish_fines_and_voiding;
2967
2968     OpenILS::Utils::Penalty->calculate_penalties(
2969         $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
2970
2971     $self->checkin_flesh_events;
2972     return;
2973 }
2974
2975 sub finish_fines_and_voiding {
2976     my $self = shift;
2977     return unless $self->circ;
2978
2979     return unless $self->backdate or $self->void_overdues;
2980
2981     # void overdues after fine generation to prevent concurrent DB access to overdue billings
2982     my $note = 'System: Amnesty Checkin' if $self->void_overdues;
2983
2984     my $evt = $CC->void_or_zero_overdues(
2985         $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
2986
2987     return $self->bail_on_events($evt) if $evt;
2988
2989     # Make sure the circ is open or closed as necessary.
2990     $evt = $U->check_open_xact($self->editor, $self->circ->id);
2991     return $self->bail_on_events($evt) if $evt;
2992
2993     return undef;
2994 }
2995
2996
2997 # if a deposit was payed for this item, push the event
2998 sub check_circ_deposit {
2999     my $self = shift;
3000     return unless $self->circ;
3001     my $deposit = $self->editor->search_money_billing(
3002         {   btype => 5, 
3003             xact => $self->circ->id, 
3004             voided => 'f'
3005         }, {idlist => 1})->[0];
3006
3007     $self->push_events(OpenILS::Event->new(
3008         'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
3009 }
3010
3011 sub reshelve_copy {
3012    my $self    = shift;
3013    my $force   = $self->force || shift;
3014    my $copy    = $self->copy;
3015
3016    my $stat = $U->copy_status($copy->status)->id;
3017
3018    my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3019
3020    if($force || (
3021       $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3022       $stat != OILS_COPY_STATUS_CATALOGING and
3023       $stat != OILS_COPY_STATUS_IN_TRANSIT and
3024       $stat != $next_status  )) {
3025
3026         $copy->status( $next_status );
3027             $self->update_copy;
3028             $self->checkin_changed(1);
3029     }
3030 }
3031
3032
3033 # Returns true if the item is at the current location
3034 # because it was transited there for a hold and the 
3035 # hold has not been fulfilled
3036 sub checkin_check_holds_shelf {
3037     my $self = shift;
3038     return 0 unless $self->copy;
3039
3040     return 0 unless 
3041         $U->copy_status($self->copy->status)->id ==
3042             OILS_COPY_STATUS_ON_HOLDS_SHELF;
3043
3044     # Attempt to clear shelf expired holds for this copy
3045     $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3046         if($self->clear_expired);
3047
3048     # find the hold that put us on the holds shelf
3049     my $holds = $self->editor->search_action_hold_request(
3050         { 
3051             current_copy => $self->copy->id,
3052