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