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