lp1861319 Auto-Renew/OPAC Renewal Compatibility
[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         if($self->opac_renewal or $self->auto_renewal) {
1028             my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
1029             if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
1030                 $self->circ_lib($self->circ->circ_lib);
1031             }
1032             else {
1033                 $self->circ_lib($patron->home_ou);
1034             }
1035         }
1036
1037         my $expire_setting = $U->ou_ancestor_setting_value($self->circ_lib, OILS_SETTING_ALLOW_RENEW_FOR_EXPIRED_PATRON);
1038         unless ($self->is_renewal and $expire_setting) {
1039             if(CORE::time > $expire->epoch) {
1040                 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1041             }
1042         }
1043     }
1044 }
1045
1046
1047 # --------------------------------------------------------------------------
1048 # Does the circ permit work
1049 # --------------------------------------------------------------------------
1050 sub do_permit {
1051     my $self = shift;
1052
1053     $self->log_me("do_permit()");
1054
1055     unless( $self->editor->requestor->id == $self->patron->id ) {
1056         return $self->bail_on_events($self->editor->event)
1057             unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1058     }
1059
1060     $self->check_captured_holds();
1061     $self->do_copy_checks();
1062     return if $self->bail_out;
1063     $self->run_patron_permit_scripts();
1064     $self->run_copy_permit_scripts() 
1065         unless $self->is_precat or $self->is_noncat;
1066     $self->check_item_deposit_events();
1067     $self->override_events();
1068     return if $self->bail_out;
1069
1070     if($self->is_precat and not $self->request_precat) {
1071         $self->push_events(
1072             OpenILS::Event->new(
1073                 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1074         return $self->bail_out(1) unless $self->is_renewal;
1075     }
1076
1077     $self->push_events(
1078         OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1079 }
1080
1081 sub check_item_deposit_events {
1082     my $self = shift;
1083     $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy)) 
1084         if $self->is_deposit and not $self->is_deposit_exempt;
1085     $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy)) 
1086         if $self->is_rental and not $self->is_rental_exempt;
1087 }
1088
1089 # returns true if the user is not required to pay deposits
1090 sub is_deposit_exempt {
1091     my $self = shift;
1092     my $pid = (ref $self->patron->profile) ?
1093         $self->patron->profile->id : $self->patron->profile;
1094     my $groups = $U->ou_ancestor_setting_value(
1095         $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1096     for my $grp (@$groups) {
1097         return 1 if $self->is_group_descendant($grp, $pid);
1098     }
1099     return 0;
1100 }
1101
1102 # returns true if the user is not required to pay rental fees
1103 sub is_rental_exempt {
1104     my $self = shift;
1105     my $pid = (ref $self->patron->profile) ?
1106         $self->patron->profile->id : $self->patron->profile;
1107     my $groups = $U->ou_ancestor_setting_value(
1108         $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1109     for my $grp (@$groups) {
1110         return 1 if $self->is_group_descendant($grp, $pid);
1111     }
1112     return 0;
1113 }
1114
1115 sub is_group_descendant {
1116     my($self, $p_id, $c_id) = @_;
1117     return 0 unless defined $p_id and defined $c_id;
1118     return 1 if $c_id == $p_id;
1119     while(my $grp = $user_groups{$c_id}) {
1120         $c_id = $grp->parent;
1121         return 0 unless defined $c_id;
1122         return 1 if $c_id == $p_id;
1123     }
1124     return 0;
1125 }
1126
1127 sub check_captured_holds {
1128     my $self    = shift;
1129     my $copy    = $self->copy;
1130     my $patron  = $self->patron;
1131
1132     return undef unless $copy;
1133
1134     my $s = $U->copy_status($copy->status)->id;
1135     return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1136     $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1137
1138     # Item is on the holds shelf, make sure it's going to the right person
1139     my $hold = $self->editor->search_action_hold_request(
1140         [
1141             { 
1142                 current_copy        => $copy->id , 
1143                 capture_time        => { '!=' => undef },
1144                 cancel_time         => undef, 
1145                 fulfillment_time    => undef 
1146             },
1147             { limit => 1,
1148               flesh => 1,
1149               flesh_fields => { ahr => ['usr'] }
1150             }
1151         ]
1152     )->[0];
1153
1154     if ($hold and $hold->usr->id == $patron->id) {
1155         $self->checkout_is_for_hold(1);
1156         return undef;
1157     } elsif ($hold) {
1158         my $payload;
1159         my $holdau = $hold->usr;
1160
1161         if ($holdau) {
1162             $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1163             $payload->{patron_id} = $holdau->id;
1164         } else {
1165             $payload->{patron_name} = "???";
1166         }
1167         $payload->{hold_id}     = $hold->id;
1168         $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1169                                                payload => $payload));
1170     }
1171
1172     $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1173
1174 }
1175
1176
1177 sub do_copy_checks {
1178     my $self = shift;
1179     my $copy = $self->copy;
1180     return unless $copy;
1181
1182     my $stat = $U->copy_status($copy->status)->id;
1183
1184     # We cannot check out a copy if it is in-transit
1185     if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1186         return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1187     }
1188
1189     $self->handle_claims_returned();
1190     return if $self->bail_out;
1191
1192     # no claims returned circ was found, check if there is any open circ
1193     unless( $self->is_renewal ) {
1194
1195         my $circs = $self->editor->search_action_circulation(
1196             { target_copy => $copy->id, checkin_time => undef }
1197         );
1198
1199         if(my $old_circ = $circs->[0]) { # an open circ was found
1200
1201             my $payload = {copy => $copy};
1202
1203             if($old_circ->usr == $self->patron->id) {
1204                 
1205                 $payload->{old_circ} = $old_circ;
1206
1207                 # If there is an open circulation on the checkout item and an auto-renew 
1208                 # interval is defined, inform the caller that they should go 
1209                 # ahead and renew the item instead of warning about open circulations.
1210     
1211                 my $auto_renew_intvl = $U->ou_ancestor_setting_value(        
1212                     $self->circ_lib,
1213                     'circ.checkout_auto_renew_age', 
1214                     $self->editor
1215                 );
1216
1217                 if($auto_renew_intvl) {
1218                     my $intvl_seconds = OpenILS::Utils::DateTime->interval_to_seconds($auto_renew_intvl);
1219                     my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( clean_ISO8601($old_circ->xact_start) );
1220
1221                     if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1222                         $payload->{auto_renew} = 1;
1223                     }
1224                 }
1225             }
1226
1227             return $self->bail_on_events(
1228                 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1229             );
1230         }
1231     }
1232 }
1233
1234 my $LEGACY_CIRC_EVENT_MAP = {
1235     'no_item' => 'ITEM_NOT_CATALOGED',
1236     'actor.usr.barred' => 'PATRON_BARRED',
1237     'asset.copy.circulate' =>  'COPY_CIRC_NOT_ALLOWED',
1238     'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1239     'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1240     'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1241     'config.circ_matrix_test.max_items_out' =>  'PATRON_EXCEEDS_CHECKOUT_COUNT',
1242     'config.circ_matrix_test.max_overdue' =>  'PATRON_EXCEEDS_OVERDUE_COUNT',
1243     'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1244     'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1245     'config.circ_matrix_test.total_copy_hold_ratio' => 
1246         'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1247     'config.circ_matrix_test.available_copy_hold_ratio' => 
1248         'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1249 };
1250
1251
1252 # ---------------------------------------------------------------------
1253 # This pushes any patron-related events into the list but does not
1254 # set bail_out for any events
1255 # ---------------------------------------------------------------------
1256 sub run_patron_permit_scripts {
1257     my $self        = shift;
1258     my $patronid    = $self->patron->id;
1259
1260     my @allevents; 
1261
1262
1263     my $results = $self->run_indb_circ_test;
1264     unless($self->circ_test_success) {
1265         my @trimmed_results;
1266
1267         if ($self->is_noncat) {
1268             # no_item result is OK during noncat checkout
1269             @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1270
1271         } else {
1272
1273             if ($self->checkout_is_for_hold) {
1274                 # if this checkout will fulfill a hold, ignore CIRC blocks
1275                 # and rely instead on the (later-checked) FULFILL block
1276
1277                 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1278                 my $fblock_pens = $self->editor->search_config_standing_penalty(
1279                     {name => [@pen_names], block_list => {like => '%CIRC%'}});
1280
1281                 for my $res (@$results) {
1282                     my $name = $res->{fail_part} || '';
1283                     next if grep {$_->name eq $name} @$fblock_pens;
1284                     push(@trimmed_results, $res);
1285                 }
1286
1287             } else { 
1288                 # not for hold or noncat
1289                 @trimmed_results = @$results;
1290             }
1291         }
1292
1293         # update the final set of test results
1294         $self->matrix_test_result(\@trimmed_results); 
1295
1296         push @allevents, $self->matrix_test_result_events;
1297     }
1298
1299     for (@allevents) {
1300        $_->{payload} = $self->copy if 
1301              ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1302     }
1303
1304     $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1305
1306     $self->push_events(@allevents);
1307 }
1308
1309 sub matrix_test_result_codes {
1310     my $self = shift;
1311     map { $_->{"fail_part"} } @{$self->matrix_test_result};
1312 }
1313
1314 sub matrix_test_result_events {
1315     my $self = shift;
1316     map {
1317         my $event = new OpenILS::Event(
1318             $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1319         );
1320         $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1321         $event;
1322     } (@{$self->matrix_test_result});
1323 }
1324
1325 sub run_indb_circ_test {
1326     my $self = shift;
1327     return $self->matrix_test_result if $self->matrix_test_result;
1328
1329     my $dbfunc = ($self->is_renewal) ? 
1330         'action.item_user_renew_test' : 'action.item_user_circ_test';
1331
1332     if( $self->is_precat && $self->request_precat) {
1333         $self->make_precat_copy;
1334         return if $self->bail_out;
1335     }
1336
1337     my $results = $self->editor->json_query(
1338         {   from => [
1339                 $dbfunc,
1340                 $self->circ_lib,
1341                 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id, 
1342                 $self->patron->id,
1343             ]
1344         }
1345     );
1346
1347     $self->circ_test_success($U->is_true($results->[0]->{success}));
1348
1349     if(my $mp = $results->[0]->{matchpoint}) {
1350         $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1351         $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1352         $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1353         if(defined($results->[0]->{renewals})) {
1354             $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1355         }
1356         $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1357         if(defined($results->[0]->{grace_period})) {
1358             $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1359         }
1360         $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1361         if(defined($results->[0]->{hard_due_date})) {
1362             $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1363         }
1364         # Grab the *last* response for limit_groups, where it is more likely to be filled
1365         $self->limit_groups($results->[-1]->{limit_groups});
1366     }
1367
1368     return $self->matrix_test_result($results);
1369 }
1370
1371 # ---------------------------------------------------------------------
1372 # given a use and copy, this will calculate the circulation policy
1373 # parameters.  Only works with in-db circ.
1374 # ---------------------------------------------------------------------
1375 sub do_inspect {
1376     my $self = shift;
1377
1378     return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1379
1380     $self->run_indb_circ_test;
1381
1382     my $results = {
1383         circ_test_success => $self->circ_test_success,
1384         failure_events => [],
1385         failure_codes => [],
1386         matchpoint => $self->circ_matrix_matchpoint
1387     };
1388
1389     unless($self->circ_test_success) {
1390         $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1391         $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1392     }
1393
1394     if($self->circ_matrix_matchpoint) {
1395         my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1396         my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1397         my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1398         my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1399     
1400         my $policy = $self->get_circ_policy(
1401             $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1402     
1403         $$results{$_} = $$policy{$_} for keys %$policy;
1404     }
1405
1406     return $results;
1407 }
1408
1409 # ---------------------------------------------------------------------
1410 # Loads the circ policy info for duration, recurring fine, and max
1411 # fine based on the current copy
1412 # ---------------------------------------------------------------------
1413 sub get_circ_policy {
1414     my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1415
1416     my $policy = {
1417         duration_rule => $duration_rule->name,
1418         recurring_fine_rule => $recurring_fine_rule->name,
1419         max_fine_rule => $max_fine_rule->name,
1420         max_fine => $self->get_max_fine_amount($max_fine_rule),
1421         fine_interval => $recurring_fine_rule->recurrence_interval,
1422         renewal_remaining => $duration_rule->max_renewals,
1423         auto_renewal_remaining => $duration_rule->max_auto_renewals,
1424         grace_period => $recurring_fine_rule->grace_period
1425     };
1426
1427     if($hard_due_date) {
1428         $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1429         $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1430     }
1431     else {
1432         $policy->{duration_date_ceiling} = undef;
1433         $policy->{duration_date_ceiling_force} = undef;
1434     }
1435
1436     $policy->{duration} = $duration_rule->shrt
1437         if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1438     $policy->{duration} = $duration_rule->normal
1439         if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1440     $policy->{duration} = $duration_rule->extended
1441         if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1442
1443     $policy->{recurring_fine} = $recurring_fine_rule->low
1444         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1445     $policy->{recurring_fine} = $recurring_fine_rule->normal
1446         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1447     $policy->{recurring_fine} = $recurring_fine_rule->high
1448         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1449
1450     return $policy;
1451 }
1452
1453 sub get_max_fine_amount {
1454     my $self = shift;
1455     my $max_fine_rule = shift;
1456     my $max_amount = $max_fine_rule->amount;
1457
1458     # if is_percent is true then the max->amount is
1459     # use as a percentage of the copy price
1460     if ($U->is_true($max_fine_rule->is_percent)) {
1461         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1462         $max_amount = $price * $max_fine_rule->amount / 100;
1463     } elsif (
1464         $U->ou_ancestor_setting_value(
1465             $self->circ_lib,
1466             'circ.max_fine.cap_at_price',
1467             $self->editor
1468         )
1469     ) {
1470         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1471         $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1472     }
1473
1474     return $max_amount;
1475 }
1476
1477
1478
1479 sub run_copy_permit_scripts {
1480     my $self = shift;
1481     my $copy = $self->copy || return;
1482
1483     my @allevents;
1484
1485     my $results = $self->run_indb_circ_test;
1486     push @allevents, $self->matrix_test_result_events
1487         unless $self->circ_test_success;
1488
1489     # See if this copy has an alert message
1490     my $ae = $self->check_copy_alert();
1491     push( @allevents, $ae ) if $ae;
1492
1493     # uniquify the events
1494     my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1495     @allevents = values %hash;
1496
1497     $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1498
1499     $self->push_events(@allevents);
1500 }
1501
1502
1503 sub check_copy_alert {
1504     my $self = shift;
1505
1506     if ($self->new_copy_alerts) {
1507         my @alerts;
1508         push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts 
1509             if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1510
1511         push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts 
1512             if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1513
1514         if (@alerts) {
1515             $self->bail_out(1) if (!$self->override);
1516             return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1517         }
1518     }
1519
1520     return undef if $self->is_renewal;
1521     return OpenILS::Event->new(
1522         'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1523         if $self->copy and $self->copy->alert_message;
1524     return undef;
1525 }
1526
1527
1528
1529 # --------------------------------------------------------------------------
1530 # If the call is overriding and has permissions to override every collected
1531 # event, the are cleared.  Any event that the caller does not have
1532 # permission to override, will be left in the event list and bail_out will
1533 # be set
1534 # XXX We need code in here to cancel any holds/transits on copies 
1535 # that are being force-checked out
1536 # --------------------------------------------------------------------------
1537 sub override_events {
1538     my $self = shift;
1539     my @events = @{$self->events};
1540     return unless @events;
1541     my $oargs = $self->override_args;
1542
1543     if(!$self->override) {
1544         return $self->bail_out(1) 
1545             if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1546     }   
1547
1548     $self->events([]);
1549     
1550     for my $e (@events) {
1551         my $tc = $e->{textcode};
1552         next if $tc eq 'SUCCESS';
1553         if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1554             my $ov = "$tc.override";
1555             $logger->info("circulator: attempting to override event: $ov");
1556
1557             return $self->bail_on_events($self->editor->event)
1558                 unless( $self->editor->allowed($ov) );
1559         } else {
1560             return $self->bail_out(1);
1561         }
1562    }
1563 }
1564     
1565
1566 # --------------------------------------------------------------------------
1567 # If there is an open claimsreturn circ on the requested copy, close the 
1568 # circ if overriding, otherwise bail out
1569 # --------------------------------------------------------------------------
1570 sub handle_claims_returned {
1571     my $self = shift;
1572     my $copy = $self->copy;
1573
1574     my $CR = $self->editor->search_action_circulation(
1575         {   
1576             target_copy     => $copy->id,
1577             stop_fines      => OILS_STOP_FINES_CLAIMSRETURNED,
1578             checkin_time    => undef,
1579         }
1580     );
1581
1582     return unless ($CR = $CR->[0]); 
1583
1584     my $evt;
1585
1586     # - If the caller has set the override flag, we will check the item in
1587     if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1588
1589         $CR->checkin_time('now');   
1590         $CR->checkin_scan_time('now');   
1591         $CR->checkin_lib($self->circ_lib);
1592         $CR->checkin_workstation($self->editor->requestor->wsid);
1593         $CR->checkin_staff($self->editor->requestor->id);
1594
1595         $evt = $self->editor->event 
1596             unless $self->editor->update_action_circulation($CR);
1597
1598     } else {
1599         $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1600     }
1601
1602     $self->bail_on_events($evt) if $evt;
1603     return;
1604 }
1605
1606
1607 # --------------------------------------------------------------------------
1608 # This performs the checkout
1609 # --------------------------------------------------------------------------
1610 sub do_checkout {
1611     my $self = shift;
1612
1613     $self->log_me("do_checkout()");
1614
1615     # make sure perms are good if this isn't a renewal
1616     unless( $self->is_renewal ) {
1617         return $self->bail_on_events($self->editor->event)
1618             unless( $self->editor->allowed('COPY_CHECKOUT') );
1619     }
1620
1621     # verify the permit key
1622     unless( $self->check_permit_key ) {
1623         if( $self->permit_override ) {
1624             return $self->bail_on_events($self->editor->event)
1625                 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1626         } else {
1627             return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1628         }   
1629     }
1630
1631     # if this is a non-cataloged circ, build the circ and finish
1632     if( $self->is_noncat ) {
1633         $self->checkout_noncat;
1634         $self->push_events(
1635             OpenILS::Event->new('SUCCESS', 
1636             payload => { noncat_circ => $self->circ }));
1637         return;
1638     }
1639
1640     if( $self->is_precat ) {
1641         $self->make_precat_copy;
1642         return if $self->bail_out;
1643
1644     } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1645         return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1646     }
1647
1648     $self->do_copy_checks;
1649     return if $self->bail_out;
1650
1651     $self->run_checkout_scripts();
1652     return if $self->bail_out;
1653
1654     $self->build_checkout_circ_object();
1655     return if $self->bail_out;
1656
1657     my $modify_to_start = $self->booking_adjusted_due_date();
1658     return if $self->bail_out;
1659
1660     $self->apply_modified_due_date($modify_to_start);
1661     return if $self->bail_out;
1662
1663     return $self->bail_on_events($self->editor->event)
1664         unless $self->editor->create_action_circulation($self->circ);
1665
1666     # refresh the circ to force local time zone for now
1667     $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1668
1669     if($self->limit_groups) {
1670         $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1671     }
1672
1673     $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1674     $self->update_copy;
1675     return if $self->bail_out;
1676
1677     $self->apply_deposit_fee();
1678     return if $self->bail_out;
1679
1680     $self->handle_checkout_holds();
1681     return if $self->bail_out;
1682
1683     # ------------------------------------------------------------------------------
1684     # Update the patron penalty info in the DB.  Run it for permit-overrides 
1685     # since the penalties are not updated during the permit phase
1686     # ------------------------------------------------------------------------------
1687     OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1688
1689     my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1690     
1691     my $pcirc;
1692     if($self->is_renewal) {
1693         # flesh the billing summary for the checked-in circ
1694         $pcirc = $self->editor->retrieve_action_circulation([
1695             $self->parent_circ,
1696             {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1697         ]);
1698     }
1699
1700     $self->push_events(
1701         OpenILS::Event->new('SUCCESS',
1702             payload  => {
1703                 copy             => $U->unflesh_copy($self->copy),
1704                 volume           => $self->volume,
1705                 circ             => $self->circ,
1706                 record           => $record,
1707                 holds_fulfilled  => $self->fulfilled_holds,
1708                 deposit_billing  => $self->deposit_billing,
1709                 rental_billing   => $self->rental_billing,
1710                 parent_circ      => $pcirc,
1711                 patron           => ($self->return_patron) ? $self->patron : undef,
1712                 patron_money     => $self->editor->retrieve_money_user_summary($self->patron->id)
1713             }
1714         )
1715     );
1716 }
1717
1718 sub apply_deposit_fee {
1719     my $self = shift;
1720     my $copy = $self->copy;
1721     return unless 
1722         ($self->is_deposit and not $self->is_deposit_exempt) or 
1723         ($self->is_rental and not $self->is_rental_exempt);
1724
1725     return if $self->is_deposit and $self->skip_deposit_fee;
1726     return if $self->is_rental and $self->skip_rental_fee;
1727
1728     my $bill = Fieldmapper::money::billing->new;
1729     my $amount = $copy->deposit_amount;
1730     my $billing_type;
1731     my $btype;
1732
1733     if($self->is_deposit) {
1734         $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1735         $btype = 5;
1736         $self->deposit_billing($bill);
1737     } else {
1738         $billing_type = OILS_BILLING_TYPE_RENTAL;
1739         $btype = 6;
1740         $self->rental_billing($bill);
1741     }
1742
1743     $bill->xact($self->circ->id);
1744     $bill->amount($amount);
1745     $bill->note(OILS_BILLING_NOTE_SYSTEM);
1746     $bill->billing_type($billing_type);
1747     $bill->btype($btype);
1748     $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1749
1750     $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1751 }
1752
1753 sub update_copy {
1754     my $self = shift;
1755     my $copy = $self->copy;
1756
1757     my $stat = $copy->status if ref $copy->status;
1758     my $loc = $copy->location if ref $copy->location;
1759     my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1760
1761     $copy->status($stat->id) if $stat;
1762     $copy->location($loc->id) if $loc;
1763     $copy->circ_lib($circ_lib->id) if $circ_lib;
1764     $copy->editor($self->editor->requestor->id);
1765     $copy->edit_date('now');
1766     $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1767
1768     return $self->bail_on_events($self->editor->event)
1769         unless $self->editor->update_asset_copy($self->copy);
1770
1771     $copy->status($U->copy_status($copy->status));
1772     $copy->location($loc) if $loc;
1773     $copy->circ_lib($circ_lib) if $circ_lib;
1774 }
1775
1776 sub update_reservation {
1777     my $self = shift;
1778     my $reservation = $self->reservation;
1779
1780     my $usr = $reservation->usr;
1781     my $target_rt = $reservation->target_resource_type;
1782     my $target_r = $reservation->target_resource;
1783     my $current_r = $reservation->current_resource;
1784
1785     $reservation->usr($usr->id) if ref $usr;
1786     $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1787     $reservation->target_resource($target_r->id) if ref $target_r;
1788     $reservation->current_resource($current_r->id) if ref $current_r;
1789
1790     return $self->bail_on_events($self->editor->event)
1791         unless $self->editor->update_booking_reservation($self->reservation);
1792
1793     my $evt;
1794     ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1795     $self->reservation($reservation);
1796 }
1797
1798
1799 sub bail_on_events {
1800     my( $self, @evts ) = @_;
1801     $self->push_events(@evts);
1802     $self->bail_out(1);
1803 }
1804
1805 # ------------------------------------------------------------------------------
1806 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1807 # affects copies that will fulfill holds and CIRC affects all other copies.
1808 # If blocks exists, bail, push Events onto the event pile, and return true.
1809 # ------------------------------------------------------------------------------
1810 sub check_hold_fulfill_blocks {
1811     my $self = shift;
1812
1813     # With the addition of ignore_proximity in csp, we need to fetch
1814     # the proximity of both the circ_lib and the copy's circ_lib to
1815     # the patron's home_ou.
1816     my ($ou_prox, $copy_prox);
1817     my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1818     $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1819     $ou_prox = -1 unless (defined($ou_prox));
1820     my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1821     if ($copy_ou == $self->circ_lib) {
1822         # Save us the time of an extra query.
1823         $copy_prox = $ou_prox;
1824     } else {
1825         $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1826         $copy_prox = -1 unless (defined($copy_prox));
1827     }
1828
1829     # See if the user has any penalties applied that prevent hold fulfillment
1830     my $pens = $self->editor->json_query({
1831         select => {csp => ['name', 'label']},
1832         from => {ausp => {csp => {}}},
1833         where => {
1834             '+ausp' => {
1835                 usr => $self->patron->id,
1836                 org_unit => $U->get_org_full_path($self->circ_lib),
1837                 '-or' => [
1838                     {stop_date => undef},
1839                     {stop_date => {'>' => 'now'}}
1840                 ]
1841             },
1842             '+csp' => {
1843                 block_list => {'like' => '%FULFILL%'},
1844                 '-or' => [
1845                     {ignore_proximity => undef},
1846                     {ignore_proximity => {'<' => $ou_prox}},
1847                     {ignore_proximity => {'<' => $copy_prox}}
1848                 ]
1849             }
1850         }
1851     });
1852
1853     return 0 unless @$pens;
1854
1855     for my $pen (@$pens) {
1856         $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1857         my $event = OpenILS::Event->new($pen->{name});
1858         $event->{desc} = $pen->{label};
1859         $self->push_events($event);
1860     }
1861
1862     $self->override_events;
1863     return $self->bail_out;
1864 }
1865
1866
1867 # ------------------------------------------------------------------------------
1868 # When an item is checked out, see if we can fulfill a hold for this patron
1869 # ------------------------------------------------------------------------------
1870 sub handle_checkout_holds {
1871    my $self    = shift;
1872    my $copy    = $self->copy;
1873    my $patron  = $self->patron;
1874
1875    my $e = $self->editor;
1876    $self->fulfilled_holds([]);
1877
1878    # non-cats can't fulfill a hold
1879    return if $self->is_noncat;
1880
1881     my $hold = $e->search_action_hold_request({   
1882         current_copy        => $copy->id , 
1883         cancel_time         => undef, 
1884         fulfillment_time    => undef
1885     })->[0];
1886
1887     if($hold and $hold->usr != $patron->id) {
1888         # reset the hold since the copy is now checked out
1889     
1890         $logger->info("circulator: un-targeting hold ".$hold->id.
1891             " because copy ".$copy->id." is getting checked out");
1892
1893         $hold->clear_prev_check_time; 
1894         $hold->clear_current_copy;
1895         $hold->clear_capture_time;
1896         $hold->clear_shelf_time;
1897         $hold->clear_shelf_expire_time;
1898         $hold->clear_current_shelf_lib;
1899
1900         return $self->bail_on_event($e->event)
1901             unless $e->update_action_hold_request($hold);
1902
1903         $hold = undef;
1904     }
1905
1906     unless($hold) {
1907         $hold = $self->find_related_user_hold($copy, $patron) or return;
1908         $logger->info("circulator: found related hold to fulfill in checkout");
1909     }
1910
1911     return if $self->check_hold_fulfill_blocks;
1912
1913     $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1914
1915     # if the hold was never officially captured, capture it.
1916     $hold->current_copy($copy->id);
1917     $hold->capture_time('now') unless $hold->capture_time;
1918     $hold->fulfillment_time('now');
1919     $hold->fulfillment_staff($e->requestor->id);
1920     $hold->fulfillment_lib($self->circ_lib);
1921
1922     return $self->bail_on_events($e->event)
1923         unless $e->update_action_hold_request($hold);
1924
1925     return $self->fulfilled_holds([$hold->id]);
1926 }
1927
1928
1929 # ------------------------------------------------------------------------------
1930 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1931 # the patron directly targets the checked out item, see if there is another hold 
1932 # for the patron that could be fulfilled by the checked out item.  Fulfill the
1933 # oldest hold and only fulfill 1 of them.
1934
1935 # For "another hold":
1936 #
1937 # First, check for one that the copy matches via hold_copy_map, ensuring that
1938 # *any* hold type that this copy could fill may end up filled.
1939 #
1940 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1941 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1942 # that are non-requestable to count as capturing those hold types.
1943 # ------------------------------------------------------------------------------
1944 sub find_related_user_hold {
1945     my($self, $copy, $patron) = @_;
1946     my $e = $self->editor;
1947
1948     # holds on precat copies are always copy-level, so this call will
1949     # always return undef.  Exit early.
1950     return undef if $self->is_precat;
1951
1952     return undef unless $U->ou_ancestor_setting_value(        
1953         $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1954
1955     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1956     my $args = {
1957         select => {ahr => ['id']}, 
1958         from => {
1959             ahr => {
1960                 ahcm => {
1961                     field => 'hold',
1962                     fkey => 'id'
1963                 },
1964                 acp => {
1965                     field => 'id', 
1966                     fkey => 'current_copy',
1967                     type => 'left' # there may be no current_copy
1968                 }
1969             }
1970         }, 
1971         where => {
1972             '+ahr' => {
1973                 usr => $patron->id,
1974                 fulfillment_time => undef,
1975                 cancel_time => undef,
1976                '-or' => [
1977                     {expire_time => undef},
1978                     {expire_time => {'>' => 'now'}}
1979                 ]
1980             },
1981             '+ahcm' => {
1982                 target_copy => $self->copy->id
1983             },
1984             '+acp' => {
1985                 '-or' => [
1986                     {id => undef}, # left-join copy may be nonexistent
1987                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1988                 ]
1989             }
1990         },
1991         order_by => {ahr => {request_time => {direction => 'asc'}}},
1992         limit => 1
1993     };
1994
1995     my $hold_info = $e->json_query($args)->[0];
1996     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1997     return undef if $U->ou_ancestor_setting_value(        
1998         $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1999
2000     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
2001     $args = {
2002         select => {ahr => ['id']}, 
2003         from => {
2004             ahr => {
2005                 acp => {
2006                     field => 'id', 
2007                     fkey => 'current_copy',
2008                     type => 'left' # there may be no current_copy
2009                 }
2010             }
2011         }, 
2012         where => {
2013             '+ahr' => {
2014                 usr => $patron->id,
2015                 fulfillment_time => undef,
2016                 cancel_time => undef,
2017                '-or' => [
2018                     {expire_time => undef},
2019                     {expire_time => {'>' => 'now'}}
2020                 ]
2021             },
2022             '-or' => [
2023                 {
2024                     '+ahr' => { 
2025                         hold_type => 'V',
2026                         target => $self->volume->id
2027                     }
2028                 },
2029                 { 
2030                     '+ahr' => { 
2031                         hold_type => 'T',
2032                         target => $self->title->id
2033                     }
2034                 },
2035             ],
2036             '+acp' => {
2037                 '-or' => [
2038                     {id => undef}, # left-join copy may be nonexistent
2039                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2040                 ]
2041             }
2042         },
2043         order_by => {ahr => {request_time => {direction => 'asc'}}},
2044         limit => 1
2045     };
2046
2047     $hold_info = $e->json_query($args)->[0];
2048     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2049     return undef;
2050 }
2051
2052
2053 sub run_checkout_scripts {
2054     my $self = shift;
2055     my $nobail = shift;
2056
2057     my $evt;
2058
2059     my $duration;
2060     my $recurring;
2061     my $max_fine;
2062     my $hard_due_date;
2063     my $duration_name;
2064     my $recurring_name;
2065     my $max_fine_name;
2066     my $hard_due_date_name;
2067
2068     $self->run_indb_circ_test();
2069     $duration = $self->circ_matrix_matchpoint->duration_rule;
2070     $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2071     $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2072     $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2073
2074     $duration_name = $duration->name if $duration;
2075     if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2076
2077         unless($duration) {
2078             ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2079             return $self->bail_on_events($evt) if ($evt && !$nobail);
2080         
2081             ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2082             return $self->bail_on_events($evt) if ($evt && !$nobail);
2083         
2084             ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2085             return $self->bail_on_events($evt) if ($evt && !$nobail);
2086
2087             if($hard_due_date_name) {
2088                 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2089                 return $self->bail_on_events($evt) if ($evt && !$nobail);
2090             }
2091         }
2092
2093     } else {
2094
2095         # The item circulates with an unlimited duration
2096         $duration   = undef;
2097         $recurring  = undef;
2098         $max_fine   = undef;
2099         $hard_due_date = undef;
2100     }
2101
2102    $self->duration_rule($duration);
2103    $self->recurring_fines_rule($recurring);
2104    $self->max_fine_rule($max_fine);
2105    $self->hard_due_date($hard_due_date);
2106 }
2107
2108
2109 sub build_checkout_circ_object {
2110     my $self = shift;
2111
2112    my $circ       = Fieldmapper::action::circulation->new;
2113    my $duration   = $self->duration_rule;
2114    my $max        = $self->max_fine_rule;
2115    my $recurring  = $self->recurring_fines_rule;
2116    my $hard_due_date    = $self->hard_due_date;
2117    my $copy       = $self->copy;
2118    my $patron     = $self->patron;
2119    my $duration_date_ceiling;
2120    my $duration_date_ceiling_force;
2121
2122     if( $duration ) {
2123
2124         my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2125         $duration_date_ceiling = $policy->{duration_date_ceiling};
2126         $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2127
2128         my $dname = $duration->name;
2129         my $mname = $max->name;
2130         my $rname = $recurring->name;
2131         my $hdname = ''; 
2132         if($hard_due_date) {
2133             $hdname = $hard_due_date->name;
2134         }
2135
2136         $logger->debug("circulator: building circulation ".
2137             "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2138     
2139         $circ->duration($policy->{duration});
2140         $circ->recurring_fine($policy->{recurring_fine});
2141         $circ->duration_rule($duration->name);
2142         $circ->recurring_fine_rule($recurring->name);
2143         $circ->max_fine_rule($max->name);
2144         $circ->max_fine($policy->{max_fine});
2145         $circ->fine_interval($recurring->recurrence_interval);
2146         $circ->renewal_remaining($duration->max_renewals);
2147         $circ->auto_renewal_remaining($duration->max_auto_renewals);
2148         $circ->grace_period($policy->{grace_period});
2149
2150     } else {
2151
2152         $logger->info("circulator: copy found with an unlimited circ duration");
2153         $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2154         $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2155         $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2156         $circ->renewal_remaining(0);
2157         $circ->grace_period(0);
2158     }
2159
2160    $circ->target_copy( $copy->id );
2161    $circ->usr( $patron->id );
2162    $circ->circ_lib( $self->circ_lib );
2163    $circ->workstation($self->editor->requestor->wsid) 
2164     if defined $self->editor->requestor->wsid;
2165
2166     # renewals maintain a link to the parent circulation
2167     $circ->parent_circ($self->parent_circ);
2168
2169    if( $self->is_renewal ) {
2170       $circ->opac_renewal('t') if $self->opac_renewal;
2171       $circ->phone_renewal('t') if $self->phone_renewal;
2172       $circ->desk_renewal('t') if $self->desk_renewal;
2173       $circ->auto_renewal('t') if $self->auto_renewal;
2174       $circ->renewal_remaining($self->renewal_remaining);
2175       $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2176       $circ->circ_staff($self->editor->requestor->id);
2177    }
2178
2179     # if the user provided an overiding checkout time,
2180     # (e.g. the checkout really happened several hours ago), then
2181     # we apply that here.  Does this need a perm??
2182     $circ->xact_start(clean_ISO8601($self->checkout_time))
2183         if $self->checkout_time;
2184
2185     # if a patron is renewing, 'requestor' will be the patron
2186     $circ->circ_staff($self->editor->requestor->id);
2187     $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2188
2189     $self->circ($circ);
2190 }
2191
2192 sub do_reservation_pickup {
2193     my $self = shift;
2194
2195     $self->log_me("do_reservation_pickup()");
2196
2197     $self->reservation->pickup_time('now');
2198
2199     if (
2200         $self->reservation->current_resource &&
2201         $U->is_true($self->reservation->target_resource_type->catalog_item)
2202     ) {
2203         # We used to try to set $self->copy and $self->patron here,
2204         # but that should already be done.
2205
2206         $self->run_checkout_scripts(1);
2207
2208         my $duration   = $self->duration_rule;
2209         my $max        = $self->max_fine_rule;
2210         my $recurring  = $self->recurring_fines_rule;
2211
2212         if ($duration && $max && $recurring) {
2213             my $policy = $self->get_circ_policy($duration, $recurring, $max);
2214
2215             my $dname = $duration->name;
2216             my $mname = $max->name;
2217             my $rname = $recurring->name;
2218
2219             $logger->debug("circulator: updating reservation ".
2220                 "with duration=$dname, maxfine=$mname, recurring=$rname");
2221
2222             $self->reservation->fine_amount($policy->{recurring_fine});
2223             $self->reservation->max_fine($policy->{max_fine});
2224             $self->reservation->fine_interval($recurring->recurrence_interval);
2225         }
2226
2227         $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2228         $self->update_copy();
2229
2230     } else {
2231         $self->reservation->fine_amount(
2232             $self->reservation->target_resource_type->fine_amount
2233         );
2234         $self->reservation->max_fine(
2235             $self->reservation->target_resource_type->max_fine
2236         );
2237         $self->reservation->fine_interval(
2238             $self->reservation->target_resource_type->fine_interval
2239         );
2240     }
2241
2242     $self->update_reservation();
2243 }
2244
2245 sub do_reservation_return {
2246     my $self = shift;
2247     my $request = shift;
2248
2249     $self->log_me("do_reservation_return()");
2250
2251     if (not ref $self->reservation) {
2252         my ($reservation, $evt) =
2253             $U->fetch_booking_reservation($self->reservation);
2254         return $self->bail_on_events($evt) if $evt;
2255         $self->reservation($reservation);
2256     }
2257
2258     $self->handle_fines(1);
2259     $self->reservation->return_time('now');
2260     $self->update_reservation();
2261     $self->reshelve_copy if $self->copy;
2262
2263     if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2264         $self->copy( $self->reservation->current_resource->catalog_item );
2265     }
2266 }
2267
2268 sub booking_adjusted_due_date {
2269     my $self = shift;
2270     my $circ = $self->circ;
2271     my $copy = $self->copy;
2272
2273     return undef unless $self->use_booking;
2274
2275     my $changed;
2276
2277     if( $self->due_date ) {
2278
2279         return $self->bail_on_events($self->editor->event)
2280             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2281
2282        $circ->due_date(clean_ISO8601($self->due_date));
2283
2284     } else {
2285
2286         return unless $copy and $circ->due_date;
2287     }
2288
2289     my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2290     if (@$booking_items) {
2291         my $booking_item = $booking_items->[0];
2292         my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2293
2294         my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2295         my $shorten_circ_setting = $resource_type->elbow_room ||
2296             $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2297             '0 seconds';
2298
2299         my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2300         my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2301               resource     => $booking_item->id
2302             , search_start => 'now'
2303             , search_end   => $circ->due_date
2304             , fields       => { cancel_time => undef, return_time => undef }
2305         })->gather(1);
2306         $booking_ses->disconnect;
2307
2308         throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2309         return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2310         
2311         my $dt_parser = DateTime::Format::ISO8601->new;
2312         my $due_date = $dt_parser->parse_datetime( clean_ISO8601($circ->due_date) );
2313
2314         for my $bid (@$bookings) {
2315
2316             my $booking = $self->editor->retrieve_booking_reservation( $bid );
2317
2318             my $booking_start = $dt_parser->parse_datetime( clean_ISO8601($booking->start_time) );
2319             my $booking_end = $dt_parser->parse_datetime( clean_ISO8601($booking->end_time) );
2320
2321             return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2322                 if ($booking_start < DateTime->now);
2323
2324
2325             if ($U->is_true($stop_circ_setting)) {
2326                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ); 
2327             } else {
2328                 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2329                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now); 
2330             }
2331             
2332             # We set the circ duration here only to affect the logic that will
2333             # later (in a DB trigger) mangle the time part of the due date to
2334             # 11:59pm. Having any circ duration that is not a whole number of
2335             # days is enough to prevent the "correction."
2336             my $new_circ_duration = $due_date->epoch - time;
2337             $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2338             $circ->duration("$new_circ_duration seconds");
2339
2340             $circ->due_date(clean_ISO8601($due_date->strftime('%FT%T%z')));
2341             $changed = 1;
2342         }
2343
2344         return $self->bail_on_events($self->editor->event)
2345             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2346     }
2347
2348     return $changed;
2349 }
2350
2351 sub apply_modified_due_date {
2352     my $self = shift;
2353     my $shift_earlier = shift;
2354     my $circ = $self->circ;
2355     my $copy = $self->copy;
2356
2357    if( $self->due_date ) {
2358
2359         return $self->bail_on_events($self->editor->event)
2360             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2361
2362       $circ->due_date(clean_ISO8601($self->due_date));
2363
2364    } else {
2365
2366       # if the due_date lands on a day when the location is closed
2367       return unless $copy and $circ->due_date;
2368
2369         #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2370
2371         # due-date overlap should be determined by the location the item
2372         # is checked out from, not the owning or circ lib of the item
2373         my $org = $self->circ_lib;
2374
2375       $logger->info("circulator: circ searching for closed date overlap on lib $org".
2376             " with an item due date of ".$circ->due_date );
2377
2378       my $dateinfo = $U->storagereq(
2379          'open-ils.storage.actor.org_unit.closed_date.overlap', 
2380             $org, $circ->due_date );
2381
2382       if($dateinfo) {
2383          $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2384             $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2385
2386             # XXX make the behavior more dynamic
2387             # for now, we just push the due date to after the close date
2388             if ($shift_earlier) {
2389                 $circ->due_date($dateinfo->{start});
2390             } else {
2391                 $circ->due_date($dateinfo->{end});
2392             }
2393       }
2394    }
2395 }
2396
2397
2398
2399 sub create_due_date {
2400     my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2401
2402     # Look up circulating library's TZ, or else use client TZ, falling
2403     # back to server TZ
2404     my $tz = $U->ou_ancestor_setting_value(
2405         $self->circ_lib,
2406         'lib.timezone',
2407         $self->editor
2408     ) || 'local';
2409
2410     my $due_date = $start_time ?
2411         DateTime::Format::ISO8601
2412             ->new
2413             ->parse_datetime(clean_ISO8601($start_time))
2414             ->set_time_zone($tz) :
2415         DateTime->now(time_zone => $tz);
2416
2417     # add the circ duration
2418     $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($duration, $due_date));
2419
2420     if($date_ceiling) {
2421         my $cdate = DateTime::Format::ISO8601
2422             ->new
2423             ->parse_datetime(clean_ISO8601($date_ceiling))
2424             ->set_time_zone($tz);
2425
2426         if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2427             $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2428             $due_date = $cdate;
2429         }
2430     }
2431
2432     # return ISO8601 time with timezone
2433     return $due_date->strftime('%FT%T%z');
2434 }
2435
2436
2437
2438 sub make_precat_copy {
2439     my $self = shift;
2440     my $copy = $self->copy;
2441     return $self->bail_on_events(OpenILS::Event->new('PERM_FAILURE'))
2442        unless $self->editor->allowed('CREATE_PRECAT') || $self->is_renewal;
2443
2444    if($copy) {
2445         $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2446
2447         $copy->editor($self->editor->requestor->id);
2448         $copy->edit_date('now');
2449         $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2450         $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2451         $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2452         $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2453         $self->update_copy();
2454         return;
2455    }
2456
2457     $logger->info("circulator: Creating a new precataloged ".
2458         "copy in checkout with barcode " . $self->copy_barcode);
2459
2460     $copy = Fieldmapper::asset::copy->new;
2461     $copy->circ_lib($self->circ_lib);
2462     $copy->creator($self->editor->requestor->id);
2463     $copy->editor($self->editor->requestor->id);
2464     $copy->barcode($self->copy_barcode);
2465     $copy->call_number(OILS_PRECAT_CALL_NUMBER); 
2466     $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2467     $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2468
2469     $copy->dummy_title($self->dummy_title || "");
2470     $copy->dummy_author($self->dummy_author || "");
2471     $copy->dummy_isbn($self->dummy_isbn || "");
2472     $copy->circ_modifier($self->circ_modifier);
2473
2474
2475     # See if we need to override the circ_lib for the copy with a configured circ_lib
2476     # Setting is shortname of the org unit
2477     my $precat_circ_lib = $U->ou_ancestor_setting_value(
2478         $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2479
2480     if($precat_circ_lib) {
2481         my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2482
2483         if(!$org) {
2484             $self->bail_on_events($self->editor->event);
2485             return;
2486         }
2487
2488         $copy->circ_lib($org->id);
2489     }
2490
2491
2492     unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2493         $self->bail_out(1);
2494         $self->push_events($self->editor->event);
2495         return;
2496     }   
2497 }
2498
2499
2500 sub checkout_noncat {
2501     my $self = shift;
2502
2503     my $circ;
2504     my $evt;
2505
2506    my $lib      = $self->noncat_circ_lib || $self->circ_lib;
2507    my $count    = $self->noncat_count || 1;
2508    my $cotime   = clean_ISO8601($self->checkout_time) || "";
2509
2510    $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2511
2512    for(1..$count) {
2513
2514       ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2515          $self->editor->requestor->id, 
2516             $self->patron->id, 
2517             $lib, 
2518             $self->noncat_type, 
2519             $cotime,
2520             $self->editor );
2521
2522         if( $evt ) {
2523             $self->push_events($evt);
2524             $self->bail_out(1);
2525             return; 
2526         }
2527         $self->circ($circ);
2528    }
2529 }
2530
2531 # if an item is in transit but the status doesn't agree, then we need to fix things.
2532 # The next two subs will hopefully do that
2533 sub fix_broken_transit_status {
2534     my $self = shift;
2535
2536     # Capture the transit so we don't have to fetch it again later during checkin
2537     # This used to live in sub check_transit_checkin_interval and later again in
2538     # do_checkin
2539     $self->transit(
2540         $self->editor->search_action_transit_copy(
2541             {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2542         )->[0]
2543     );
2544
2545     if ($self->transit && $U->copy_status($self->copy->status)->id != OILS_COPY_STATUS_IN_TRANSIT) {
2546         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2547             " that is in-transit but without the In Transit status... fixing");
2548         $self->copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2549         # FIXME - do we want to make this permanent if the checkin bails?
2550         $self->update_copy;
2551     }
2552
2553 }
2554 sub cancel_transit_if_circ_exists {
2555     my $self = shift;
2556     if ($self->circ && $self->transit) {
2557         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2558             " that is in-transit AND circulating... aborting the transit");
2559         my $circ_ses = create OpenSRF::AppSession("open-ils.circ");
2560         my $result = $circ_ses->request(
2561             "open-ils.circ.transit.abort",
2562             $self->editor->authtoken,
2563             { 'transitid' => $self->transit->id }
2564         )->gather(1);
2565         $logger->warn("circulator: transit abort result: ".$result);
2566         $circ_ses->disconnect;
2567         $self->transit(undef);
2568     }
2569 }
2570
2571 # If a copy goes into transit and is then checked in before the transit checkin 
2572 # interval has expired, push an event onto the overridable events list.
2573 sub check_transit_checkin_interval {
2574     my $self = shift;
2575
2576     # only concerned with in-transit items
2577     return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2578
2579     # no interval, no problem
2580     my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2581     return unless $interval;
2582
2583     # transit from X to X for whatever reason has no min interval
2584     return if $self->transit->source == $self->transit->dest;
2585
2586     my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2587     my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2588     my $horizon = $t_start->add(seconds => $seconds);
2589
2590     # See if we are still within the transit checkin forbidden range
2591     $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK')) 
2592         if $horizon > DateTime->now;
2593 }
2594
2595 # Retarget local holds at checkin
2596 sub checkin_retarget {
2597     my $self = shift;
2598     return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2599     return unless $self->is_checkin; # Renewals need not be checked
2600     return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2601     return if $self->is_precat; # No holds for precats
2602     return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2603     return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2604     my $status = $U->copy_status($self->copy->status);
2605     return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2606     # Specifically target items that are likely new (by status ID)
2607     return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2608     my $location = $self->copy->location;
2609     if(!ref($location)) {
2610         $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2611         $self->copy->location($location);
2612     }
2613     return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2614
2615     # Fetch holds for the bib
2616     my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2617                     $self->editor->authtoken,
2618                     $self->title->id,
2619                     {
2620                         capture_time => undef, # No touching captured holds
2621                         frozen => 'f', # Don't bother with frozen holds
2622                         pickup_lib => $self->circ_lib # Only holds actually here
2623                     }); 
2624
2625     # Error? Skip the step.
2626     return if exists $result->{"ilsevent"};
2627
2628     # Assemble holds
2629     my $holds = [];
2630     foreach my $holdlist (keys %{$result}) {
2631         push @$holds, @{$result->{$holdlist}};
2632     }
2633
2634     return if scalar(@$holds) == 0; # No holds, no retargeting
2635
2636     # Check for parts on this copy
2637     my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2638     my %parts_hash = ();
2639     %parts_hash = map {$_->part, 1} @$parts if @$parts;
2640
2641     # Loop over holds in request-ish order
2642     # Stage 1: Get them into request-ish order
2643     # Also grab type and target for skipping low hanging ones
2644     $result = $self->editor->json_query({
2645         "select" => { "ahr" => ["id", "hold_type", "target"] },
2646         "from" => { "ahr" => { "au" => { "fkey" => "usr",  "join" => "pgt"} } },
2647         "where" => { "id" => $holds },
2648         "order_by" => [
2649             { "class" => "pgt", "field" => "hold_priority"},
2650             { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2651             { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2652             { "class" => "ahr", "field" => "request_time"}
2653         ]
2654     });
2655
2656     # Stage 2: Loop!
2657     if (ref $result eq "ARRAY" and scalar @$result) {
2658         foreach (@{$result}) {
2659             # Copy level, but not this copy?
2660             next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2661                 and $_->{target} != $self->copy->id);
2662             # Volume level, but not this volume?
2663             next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2664             if(@$parts) { # We have parts?
2665                 # Skip title holds
2666                 next if ($_->{hold_type} eq 'T');
2667                 # Skip part holds for parts not on this copy
2668                 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2669             } else {
2670                 # No parts, no part holds
2671                 next if ($_->{hold_type} eq 'P');
2672             }
2673             # So much for easy stuff, attempt a retarget!
2674             my $tresult = $U->simplereq(
2675                 'open-ils.hold-targeter',
2676                 'open-ils.hold-targeter.target', 
2677                 {hold => $_->{id}, find_copy => $self->copy->id}
2678             );
2679             if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2680                 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2681             }
2682         }
2683     }
2684 }
2685
2686 sub do_checkin {
2687     my $self = shift;
2688     $self->log_me("do_checkin()");
2689
2690     return $self->bail_on_events(
2691         OpenILS::Event->new('ASSET_COPY_NOT_FOUND')) 
2692         unless $self->copy;
2693
2694     $self->fix_broken_transit_status; # if applicable
2695     $self->check_transit_checkin_interval;
2696     $self->checkin_retarget;
2697
2698     # the renew code and mk_env should have already found our circulation object
2699     unless( $self->circ ) {
2700
2701         my $circs = $self->editor->search_action_circulation(
2702             { target_copy => $self->copy->id, checkin_time => undef });
2703
2704         $self->circ($$circs[0]);
2705
2706         # for now, just warn if there are multiple open circs on a copy
2707         $logger->warn("circulator: we have ".scalar(@$circs).
2708             " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2709     }
2710     $self->cancel_transit_if_circ_exists; # if applicable
2711
2712     my $stat = $U->copy_status($self->copy->status)->id;
2713
2714     # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2715     # differently if they are already paid for.  We need to check for this
2716     # early since overdue generation is potentially affected.
2717     my $dont_change_lost_zero = 0;
2718     if ($stat == OILS_COPY_STATUS_LOST
2719         || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2720         || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2721
2722         # LOST fine settings are controlled by the copy's circ lib, not the the
2723         # circulation's
2724         my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2725                 $self->copy->circ_lib->id : $self->copy->circ_lib;
2726         $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2727             $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2728             $self->editor) || 0;
2729
2730         # Don't assume there's always a circ based on copy status
2731         if ($dont_change_lost_zero && $self->circ) {
2732             my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2733             $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2734         }
2735
2736         $self->dont_change_lost_zero($dont_change_lost_zero);
2737     }
2738
2739     my $latest_inventory = Fieldmapper::asset::latest_inventory->new;
2740
2741     if ($self->do_inventory_update) {
2742         $latest_inventory->inventory_date('now');
2743         $latest_inventory->inventory_workstation($self->editor->requestor->wsid);
2744         $latest_inventory->copy($self->copy->id());
2745     } else {
2746         my $alci = $self->editor->search_asset_latest_inventory(
2747             {copy => $self->copy->id}
2748         );
2749         $latest_inventory = $alci->[0]
2750     }
2751     $self->latest_inventory($latest_inventory);
2752
2753     if( $self->checkin_check_holds_shelf() ) {
2754         $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2755         $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2756         if($self->fake_hold_dest) {
2757             $self->hold->pickup_lib($self->circ_lib);
2758         }
2759         $self->checkin_flesh_events;
2760         return;
2761     }
2762
2763     unless( $self->is_renewal ) {
2764         return $self->bail_on_events($self->editor->event)
2765             unless $self->editor->allowed('COPY_CHECKIN');
2766     }
2767
2768     $self->push_events($self->check_copy_alert());
2769     $self->push_events($self->check_checkin_copy_status());
2770
2771     # if the circ is marked as 'claims returned', add the event to the list
2772     $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2773         if ($self->circ and $self->circ->stop_fines 
2774                 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2775
2776     $self->check_circ_deposit();
2777
2778     # handle the overridable events 
2779     $self->override_events unless $self->is_renewal;
2780     return if $self->bail_out;
2781     
2782     if( $self->circ ) {
2783         $self->checkin_handle_circ_start;
2784         return if $self->bail_out;
2785
2786         if (!$dont_change_lost_zero) {
2787             # if this circ is LOST and we are configured to generate overdue
2788             # fines for lost items on checkin (to fill the gap between mark
2789             # lost time and when the fines would have naturally stopped), then
2790             # stop_fines is no longer valid and should be cleared.
2791             #
2792             # stop_fines will be set again during the handle_fines() stage.
2793             # XXX should this setting come from the copy circ lib (like other
2794             # LOST settings), instead of the circulation circ lib?
2795             if ($stat == OILS_COPY_STATUS_LOST) {
2796                 $self->circ->clear_stop_fines if
2797                     $U->ou_ancestor_setting_value(
2798                         $self->circ_lib,
2799                         OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2800                         $self->editor
2801                     );
2802             }
2803
2804             # Set stop_fines when claimed never checked out
2805             $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2806
2807             # handle fines for this circ, including overdue gen if needed
2808             $self->handle_fines;
2809         }
2810
2811         $self->checkin_handle_circ_finish;
2812         return if $self->bail_out;
2813         $self->checkin_changed(1);
2814
2815     } elsif( $self->transit ) {
2816         my $hold_transit = $self->process_received_transit;
2817         $self->checkin_changed(1);
2818
2819         if( $self->bail_out ) { 
2820             $self->checkin_flesh_events;
2821             return;
2822         }
2823         
2824         if( my $e = $self->check_checkin_copy_status() ) {
2825             # If the original copy status is special, alert the caller
2826             my $ev = $self->events;
2827             $self->events([$e]);
2828             $self->override_events;
2829             return if $self->bail_out;
2830             $self->events($ev);
2831         }
2832
2833         if( $hold_transit or 
2834                 $U->copy_status($self->copy->status)->id 
2835                     == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2836
2837             my $hold;
2838             if( $hold_transit ) {
2839                $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2840             } else {
2841                    ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2842             }
2843
2844             $self->hold($hold);
2845
2846             if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2847
2848                 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2849                 $self->reshelve_copy(1);
2850                 $self->cancelled_hold_transit(1);
2851                 $self->notify_hold(0); # don't notify for cancelled holds
2852                 $self->fake_hold_dest(0);
2853                 return if $self->bail_out;
2854
2855             } elsif ($hold and $hold->hold_type eq 'R') {
2856
2857                 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2858                 $self->notify_hold(0); # No need to notify
2859                 $self->fake_hold_dest(0);
2860                 $self->noop(1); # Don't try and capture for other holds/transits now
2861                 $self->update_copy();
2862                 $hold->fulfillment_time('now');
2863                 $self->bail_on_events($self->editor->event)
2864                     unless $self->editor->update_action_hold_request($hold);
2865
2866             } else {
2867
2868                 # hold transited to correct location
2869                 if($self->fake_hold_dest) {
2870                     $hold->pickup_lib($self->circ_lib);
2871                 }
2872                 $self->checkin_flesh_events;
2873                 return;
2874             }
2875         } 
2876
2877     } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2878
2879         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2880             " that is in-transit, but there is no transit.. repairing");
2881         $self->reshelve_copy(1);
2882         return if $self->bail_out;
2883     }
2884
2885     if( $self->is_renewal ) {
2886         $self->finish_fines_and_voiding;
2887         return if $self->bail_out;
2888         $self->push_events(OpenILS::Event->new('SUCCESS'));
2889         return;
2890     }
2891
2892    # ------------------------------------------------------------------------------
2893    # Circulations and transits are now closed where necessary.  Now go on to see if
2894    # this copy can fulfill a hold or needs to be routed to a different location
2895    # ------------------------------------------------------------------------------
2896
2897     my $needed_for_something = 0; # formerly "needed_for_hold"
2898
2899     if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2900
2901         if (!$self->remote_hold) {
2902             if ($self->use_booking) {
2903                 my $potential_hold = $self->hold_capture_is_possible;
2904                 my $potential_reservation = $self->reservation_capture_is_possible;
2905
2906                 if ($potential_hold and $potential_reservation) {
2907                     $logger->info("circulator: item could fulfill either hold or reservation");
2908                     $self->push_events(new OpenILS::Event(
2909                         "HOLD_RESERVATION_CONFLICT",
2910                         "hold" => $potential_hold,
2911                         "reservation" => $potential_reservation
2912                     ));
2913                     return if $self->bail_out;
2914                 } elsif ($potential_hold) {
2915                     $needed_for_something =
2916                         $self->attempt_checkin_hold_capture;
2917                 } elsif ($potential_reservation) {
2918                     $needed_for_something =
2919                         $self->attempt_checkin_reservation_capture;
2920                 }
2921             } else {
2922                 $needed_for_something = $self->attempt_checkin_hold_capture;
2923             }
2924         }
2925         return if $self->bail_out;
2926     
2927         unless($needed_for_something) {
2928             my $circ_lib = (ref $self->copy->circ_lib) ? 
2929                     $self->copy->circ_lib->id : $self->copy->circ_lib;
2930     
2931             if( $self->remote_hold ) {
2932                 $circ_lib = $self->remote_hold->pickup_lib;
2933                 $logger->warn("circulator: Copy ".$self->copy->barcode.
2934                     " is on a remote hold's shelf, sending to $circ_lib");
2935             }
2936     
2937             $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2938
2939             my $suppress_transit = 0;
2940
2941             if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2942                 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2943                 if($suppress_transit_source && $suppress_transit_source->{value}) {
2944                     my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2945                     if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2946                         $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2947                         $suppress_transit = 1;
2948                     }
2949                 }
2950             }
2951  
2952             if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2953                 # copy is where it needs to be, either for hold or reshelving
2954     
2955                 $self->checkin_handle_precat();
2956                 return if $self->bail_out;
2957     
2958             } else {
2959                 # copy needs to transit "home", or stick here if it's a floating copy
2960                 my $can_float = 0;
2961                 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2962                     my $res = $self->editor->json_query(
2963                         {   from => [
2964                                 'evergreen.can_float',
2965                                 $self->copy->floating->id,
2966                                 $self->copy->circ_lib,
2967                                 $self->circ_lib
2968                             ]
2969                         }
2970                     );
2971                     $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res; 
2972                 }
2973                 if ($can_float) { # Yep, floating, stick here
2974                     $self->checkin_changed(1);
2975                     $self->copy->circ_lib( $self->circ_lib );
2976                     $self->update_copy;
2977                 } else {
2978                     my $bc = $self->copy->barcode;
2979                     $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2980                     $self->checkin_build_copy_transit($circ_lib);
2981                     return if $self->bail_out;
2982                     $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2983                 }
2984             }
2985         }
2986     } else { # no-op checkin
2987         if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2988             my $res = $self->editor->json_query(
2989                 {
2990                     from => [
2991                         'evergreen.can_float',
2992                         $self->copy->floating->id,
2993                         $self->copy->circ_lib,
2994                         $self->circ_lib
2995                     ]
2996                 }
2997             );
2998             if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2999                 $self->checkin_changed(1);
3000                 $self->copy->circ_lib( $self->circ_lib );
3001                 $self->update_copy;
3002             }
3003         }
3004     }
3005
3006     if($self->claims_never_checked_out and 
3007             $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
3008
3009         # the item was not supposed to be checked out to the user and should now be marked as missing
3010         my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
3011         $self->copy->status($next_status);
3012         $self->update_copy;
3013
3014     } else {
3015         $self->reshelve_copy unless $needed_for_something;
3016     }
3017
3018     return if $self->bail_out;
3019
3020     unless($self->checkin_changed) {
3021
3022         $self->push_events(OpenILS::Event->new('NO_CHANGE'));
3023         my $stat = $U->copy_status($self->copy->status)->id;
3024
3025         $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
3026          if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
3027         $self->bail_out(1); # no need to commit anything
3028
3029     } else {
3030
3031         $self->push_events(OpenILS::Event->new('SUCCESS')) 
3032             unless @{$self->events};
3033     }
3034
3035     $self->finish_fines_and_voiding;
3036
3037     OpenILS::Utils::Penalty->calculate_penalties(
3038         $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3039
3040     $self->checkin_flesh_events;
3041     return;
3042 }
3043
3044 sub finish_fines_and_voiding {
3045     my $self = shift;
3046     return unless $self->circ;
3047
3048     return unless $self->backdate or $self->void_overdues;
3049
3050     # void overdues after fine generation to prevent concurrent DB access to overdue billings
3051     my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3052
3053     my $evt = $CC->void_or_zero_overdues(
3054         $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3055
3056     return $self->bail_on_events($evt) if $evt;
3057
3058     # Make sure the circ is open or closed as necessary.
3059     $evt = $U->check_open_xact($self->editor, $self->circ->id);
3060     return $self->bail_on_events($evt) if $evt;
3061
3062     return undef;
3063 }
3064
3065
3066 # if a deposit was payed for this item, push the event
3067 sub check_circ_deposit {
3068     my $self = shift;
3069     return unless $self->circ;
3070     my $deposit = $self->editor->search_money_billing(
3071         {   btype => 5, 
3072             xact => $self->circ->id, 
3073             voided => 'f'
3074         }, {idlist => 1})->[0];
3075
3076     $self->push_events(OpenILS::Event->new(
3077         'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
3078 }
3079
3080 sub reshelve_copy {
3081    my $self    = shift;
3082    my $force   = $self->force || shift;
3083    my $copy    = $self->copy;
3084
3085    my $stat = $U->copy_status($copy->status)->id;
3086
3087    my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3088
3089    if($force || (
3090       $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3091       $stat != OILS_COPY_STATUS_CATALOGING and
3092       $stat != OILS_COPY_STATUS_IN_TRANSIT and
3093       $stat != $next_status  )) {
3094
3095         $copy->status( $next_status );
3096             $self->update_copy;
3097             $self->checkin_changed(1);
3098     }
3099 }
3100
3101
3102 # Returns true if the item is at the current location
3103 # because it was transited there for a hold and the 
3104 # hold has not been fulfilled
3105 sub checkin_check_holds_shelf {
3106     my $self = shift;
3107     return 0 unless $self->copy;
3108
3109     return 0 unless 
3110         $U->copy_status($self->copy->status)->id ==
3111             OILS_COPY_STATUS_ON_HOLDS_SHELF;
3112
3113     # Attempt to clear shelf expired holds for this copy
3114     $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3115         if($self->clear_expired);
3116
3117     # find the hold that put us on the holds shelf
3118     my $holds = $self->editor->search_action_hold_request(
3119         { 
3120             current_copy => $self->copy->id,
3121             capture_time => { '!=' => undef },
3122             fulfillment_time => undef,
3123             cancel_time => undef,