]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
7ac877b069acffca2621da412c4d6f3fcc677837
[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         # Expired patrons cannot check out.  Renewals for expired
1024         # patrons depend on a setting and will be checked in the
1025         # do_renew subroutine.
1026         if ($self->is_checkout) {
1027             my $expire = DateTime::Format::ISO8601->new->parse_datetime(
1028                 clean_ISO8601($patron->expire_date));
1029
1030             if (CORE::time > $expire->epoch) {
1031                 $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'))
1032             }
1033         }
1034     }
1035 }
1036
1037
1038 # --------------------------------------------------------------------------
1039 # Does the circ permit work
1040 # --------------------------------------------------------------------------
1041 sub do_permit {
1042     my $self = shift;
1043
1044     $self->log_me("do_permit()");
1045
1046     unless( $self->editor->requestor->id == $self->patron->id ) {
1047         return $self->bail_on_events($self->editor->event)
1048             unless( $self->editor->allowed('VIEW_PERMIT_CHECKOUT') );
1049     }
1050
1051     $self->check_captured_holds();
1052     $self->do_copy_checks();
1053     return if $self->bail_out;
1054     $self->run_patron_permit_scripts();
1055     $self->run_copy_permit_scripts() 
1056         unless $self->is_precat or $self->is_noncat;
1057     $self->check_item_deposit_events();
1058     $self->override_events();
1059     return if $self->bail_out;
1060
1061     if($self->is_precat and not $self->request_precat) {
1062         $self->push_events(
1063             OpenILS::Event->new(
1064                 'ITEM_NOT_CATALOGED', payload => $self->mk_permit_key));
1065         return $self->bail_out(1) unless $self->is_renewal;
1066     }
1067
1068     $self->push_events(
1069         OpenILS::Event->new('SUCCESS', payload => $self->mk_permit_key));
1070 }
1071
1072 sub check_item_deposit_events {
1073     my $self = shift;
1074     $self->push_events(OpenILS::Event->new('ITEM_DEPOSIT_REQUIRED', payload => $self->copy)) 
1075         if $self->is_deposit and not $self->is_deposit_exempt;
1076     $self->push_events(OpenILS::Event->new('ITEM_RENTAL_FEE_REQUIRED', payload => $self->copy)) 
1077         if $self->is_rental and not $self->is_rental_exempt;
1078 }
1079
1080 # returns true if the user is not required to pay deposits
1081 sub is_deposit_exempt {
1082     my $self = shift;
1083     my $pid = (ref $self->patron->profile) ?
1084         $self->patron->profile->id : $self->patron->profile;
1085     my $groups = $U->ou_ancestor_setting_value(
1086         $self->circ_lib, 'circ.deposit.exempt_groups', $self->editor);
1087     for my $grp (@$groups) {
1088         return 1 if $self->is_group_descendant($grp, $pid);
1089     }
1090     return 0;
1091 }
1092
1093 # returns true if the user is not required to pay rental fees
1094 sub is_rental_exempt {
1095     my $self = shift;
1096     my $pid = (ref $self->patron->profile) ?
1097         $self->patron->profile->id : $self->patron->profile;
1098     my $groups = $U->ou_ancestor_setting_value(
1099         $self->circ_lib, 'circ.rental.exempt_groups', $self->editor);
1100     for my $grp (@$groups) {
1101         return 1 if $self->is_group_descendant($grp, $pid);
1102     }
1103     return 0;
1104 }
1105
1106 sub is_group_descendant {
1107     my($self, $p_id, $c_id) = @_;
1108     return 0 unless defined $p_id and defined $c_id;
1109     return 1 if $c_id == $p_id;
1110     while(my $grp = $user_groups{$c_id}) {
1111         $c_id = $grp->parent;
1112         return 0 unless defined $c_id;
1113         return 1 if $c_id == $p_id;
1114     }
1115     return 0;
1116 }
1117
1118 sub check_captured_holds {
1119     my $self    = shift;
1120     my $copy    = $self->copy;
1121     my $patron  = $self->patron;
1122
1123     return undef unless $copy;
1124
1125     my $s = $U->copy_status($copy->status)->id;
1126     return unless $s == OILS_COPY_STATUS_ON_HOLDS_SHELF;
1127     $logger->info("circulator: copy is on holds shelf, searching for the correct hold");
1128
1129     # Item is on the holds shelf, make sure it's going to the right person
1130     my $hold = $self->editor->search_action_hold_request(
1131         [
1132             { 
1133                 current_copy        => $copy->id , 
1134                 capture_time        => { '!=' => undef },
1135                 cancel_time         => undef, 
1136                 fulfillment_time    => undef 
1137             },
1138             { limit => 1,
1139               flesh => 1,
1140               flesh_fields => { ahr => ['usr'] }
1141             }
1142         ]
1143     )->[0];
1144
1145     if ($hold and $hold->usr->id == $patron->id) {
1146         $self->checkout_is_for_hold(1);
1147         return undef;
1148     } elsif ($hold) {
1149         my $payload;
1150         my $holdau = $hold->usr;
1151
1152         if ($holdau) {
1153             $payload->{patron_name} = $holdau->first_given_name . ' ' . $holdau->family_name;
1154             $payload->{patron_id} = $holdau->id;
1155         } else {
1156             $payload->{patron_name} = "???";
1157         }
1158         $payload->{hold_id}     = $hold->id;
1159         $self->push_events(OpenILS::Event->new('ITEM_ON_HOLDS_SHELF',
1160                                                payload => $payload));
1161     }
1162
1163     $logger->info("circulator: this copy is needed by a different patron to fulfill a hold");
1164
1165 }
1166
1167
1168 sub do_copy_checks {
1169     my $self = shift;
1170     my $copy = $self->copy;
1171     return unless $copy;
1172
1173     my $stat = $U->copy_status($copy->status)->id;
1174
1175     # We cannot check out a copy if it is in-transit
1176     if( $stat == OILS_COPY_STATUS_IN_TRANSIT ) {
1177         return $self->bail_on_events(OpenILS::Event->new('COPY_IN_TRANSIT'));
1178     }
1179
1180     $self->handle_claims_returned();
1181     return if $self->bail_out;
1182
1183     # no claims returned circ was found, check if there is any open circ
1184     unless( $self->is_renewal ) {
1185
1186         my $circs = $self->editor->search_action_circulation(
1187             { target_copy => $copy->id, checkin_time => undef }
1188         );
1189
1190         if(my $old_circ = $circs->[0]) { # an open circ was found
1191
1192             my $payload = {copy => $copy};
1193
1194             if($old_circ->usr == $self->patron->id) {
1195                 
1196                 $payload->{old_circ} = $old_circ;
1197
1198                 # If there is an open circulation on the checkout item and an auto-renew 
1199                 # interval is defined, inform the caller that they should go 
1200                 # ahead and renew the item instead of warning about open circulations.
1201     
1202                 my $auto_renew_intvl = $U->ou_ancestor_setting_value(        
1203                     $self->circ_lib,
1204                     'circ.checkout_auto_renew_age', 
1205                     $self->editor
1206                 );
1207
1208                 if($auto_renew_intvl) {
1209                     my $intvl_seconds = OpenILS::Utils::DateTime->interval_to_seconds($auto_renew_intvl);
1210                     my $checkout_time = DateTime::Format::ISO8601->new->parse_datetime( clean_ISO8601($old_circ->xact_start) );
1211
1212                     if(DateTime->now > $checkout_time->add(seconds => $intvl_seconds)) {
1213                         $payload->{auto_renew} = 1;
1214                     }
1215                 }
1216             }
1217
1218             return $self->bail_on_events(
1219                 OpenILS::Event->new('OPEN_CIRCULATION_EXISTS', payload => $payload)
1220             );
1221         }
1222     }
1223 }
1224
1225 my $LEGACY_CIRC_EVENT_MAP = {
1226     'no_item' => 'ITEM_NOT_CATALOGED',
1227     'actor.usr.barred' => 'PATRON_BARRED',
1228     'asset.copy.circulate' =>  'COPY_CIRC_NOT_ALLOWED',
1229     'asset.copy.status' => 'COPY_NOT_AVAILABLE',
1230     'asset.copy_location.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1231     'config.circ_matrix_test.circulate' => 'COPY_CIRC_NOT_ALLOWED',
1232     'config.circ_matrix_test.max_items_out' =>  'PATRON_EXCEEDS_CHECKOUT_COUNT',
1233     'config.circ_matrix_test.max_overdue' =>  'PATRON_EXCEEDS_OVERDUE_COUNT',
1234     'config.circ_matrix_test.max_fines' => 'PATRON_EXCEEDS_FINES',
1235     'config.circ_matrix_circ_mod_test' => 'PATRON_EXCEEDS_CHECKOUT_COUNT',
1236     'config.circ_matrix_test.total_copy_hold_ratio' => 
1237         'TOTAL_HOLD_COPY_RATIO_EXCEEDED',
1238     'config.circ_matrix_test.available_copy_hold_ratio' => 
1239         'AVAIL_HOLD_COPY_RATIO_EXCEEDED'
1240 };
1241
1242
1243 # ---------------------------------------------------------------------
1244 # This pushes any patron-related events into the list but does not
1245 # set bail_out for any events
1246 # ---------------------------------------------------------------------
1247 sub run_patron_permit_scripts {
1248     my $self        = shift;
1249     my $patronid    = $self->patron->id;
1250
1251     my @allevents; 
1252
1253
1254     my $results = $self->run_indb_circ_test;
1255     unless($self->circ_test_success) {
1256         my @trimmed_results;
1257
1258         if ($self->is_noncat) {
1259             # no_item result is OK during noncat checkout
1260             @trimmed_results = grep { ($_->{fail_part} || '') ne 'no_item' } @$results;
1261
1262         } else {
1263
1264             if ($self->checkout_is_for_hold) {
1265                 # if this checkout will fulfill a hold, ignore CIRC blocks
1266                 # and rely instead on the (later-checked) FULFILL block
1267
1268                 my @pen_names = grep {$_} map {$_->{fail_part}} @$results;
1269                 my $fblock_pens = $self->editor->search_config_standing_penalty(
1270                     {name => [@pen_names], block_list => {like => '%CIRC%'}});
1271
1272                 for my $res (@$results) {
1273                     my $name = $res->{fail_part} || '';
1274                     next if grep {$_->name eq $name} @$fblock_pens;
1275                     push(@trimmed_results, $res);
1276                 }
1277
1278             } else { 
1279                 # not for hold or noncat
1280                 @trimmed_results = @$results;
1281             }
1282         }
1283
1284         # update the final set of test results
1285         $self->matrix_test_result(\@trimmed_results); 
1286
1287         push @allevents, $self->matrix_test_result_events;
1288     }
1289
1290     for (@allevents) {
1291        $_->{payload} = $self->copy if 
1292              ($_->{textcode} eq 'COPY_NOT_AVAILABLE');
1293     }
1294
1295     $logger->info("circulator: permit_patron script returned events: @allevents") if @allevents;
1296
1297     $self->push_events(@allevents);
1298 }
1299
1300 sub matrix_test_result_codes {
1301     my $self = shift;
1302     map { $_->{"fail_part"} } @{$self->matrix_test_result};
1303 }
1304
1305 sub matrix_test_result_events {
1306     my $self = shift;
1307     map {
1308         my $event = new OpenILS::Event(
1309             $LEGACY_CIRC_EVENT_MAP->{$_->{"fail_part"}} || $_->{"fail_part"}
1310         );
1311         $event->{"payload"} = {"fail_part" => $_->{"fail_part"}};
1312         $event;
1313     } (@{$self->matrix_test_result});
1314 }
1315
1316 sub run_indb_circ_test {
1317     my $self = shift;
1318     return $self->matrix_test_result if $self->matrix_test_result;
1319
1320     my $dbfunc = ($self->is_renewal) ? 
1321         'action.item_user_renew_test' : 'action.item_user_circ_test';
1322
1323     if( $self->is_precat && $self->request_precat) {
1324         $self->make_precat_copy;
1325         return if $self->bail_out;
1326     }
1327
1328     my $results = $self->editor->json_query(
1329         {   from => [
1330                 $dbfunc,
1331                 $self->circ_lib,
1332                 ($self->is_noncat or ($self->is_precat and !$self->override and !$self->is_renewal)) ? undef : $self->copy->id, 
1333                 $self->patron->id,
1334             ]
1335         }
1336     );
1337
1338     $self->circ_test_success($U->is_true($results->[0]->{success}));
1339
1340     if(my $mp = $results->[0]->{matchpoint}) {
1341         $logger->info("circulator: circ policy test found matchpoint built via rows " . $results->[0]->{buildrows});
1342         $self->circ_matrix_matchpoint($self->editor->retrieve_config_circ_matrix_matchpoint($mp));
1343         $self->circ_matrix_matchpoint->duration_rule($self->editor->retrieve_config_rules_circ_duration($results->[0]->{duration_rule}));
1344         if(defined($results->[0]->{renewals})) {
1345             $self->circ_matrix_matchpoint->duration_rule->max_renewals($results->[0]->{renewals});
1346         }
1347         $self->circ_matrix_matchpoint->recurring_fine_rule($self->editor->retrieve_config_rules_recurring_fine($results->[0]->{recurring_fine_rule}));
1348         if(defined($results->[0]->{grace_period})) {
1349             $self->circ_matrix_matchpoint->recurring_fine_rule->grace_period($results->[0]->{grace_period});
1350         }
1351         $self->circ_matrix_matchpoint->max_fine_rule($self->editor->retrieve_config_rules_max_fine($results->[0]->{max_fine_rule}));
1352         if(defined($results->[0]->{hard_due_date})) {
1353             $self->circ_matrix_matchpoint->hard_due_date($self->editor->retrieve_config_hard_due_date($results->[0]->{hard_due_date}));
1354         }
1355         # Grab the *last* response for limit_groups, where it is more likely to be filled
1356         $self->limit_groups($results->[-1]->{limit_groups});
1357     }
1358
1359     return $self->matrix_test_result($results);
1360 }
1361
1362 # ---------------------------------------------------------------------
1363 # given a use and copy, this will calculate the circulation policy
1364 # parameters.  Only works with in-db circ.
1365 # ---------------------------------------------------------------------
1366 sub do_inspect {
1367     my $self = shift;
1368
1369     return OpenILS::Event->new('ASSET_COPY_NOT_FOUND') unless $self->copy;
1370
1371     $self->run_indb_circ_test;
1372
1373     my $results = {
1374         circ_test_success => $self->circ_test_success,
1375         failure_events => [],
1376         failure_codes => [],
1377         matchpoint => $self->circ_matrix_matchpoint
1378     };
1379
1380     unless($self->circ_test_success) {
1381         $results->{"failure_codes"} = [ $self->matrix_test_result_codes ];
1382         $results->{"failure_events"} = [ $self->matrix_test_result_events ];
1383     }
1384
1385     if($self->circ_matrix_matchpoint) {
1386         my $duration_rule = $self->circ_matrix_matchpoint->duration_rule;
1387         my $recurring_fine_rule = $self->circ_matrix_matchpoint->recurring_fine_rule;
1388         my $max_fine_rule = $self->circ_matrix_matchpoint->max_fine_rule;
1389         my $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
1390     
1391         my $policy = $self->get_circ_policy(
1392             $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date);
1393     
1394         $$results{$_} = $$policy{$_} for keys %$policy;
1395     }
1396
1397     return $results;
1398 }
1399
1400 # ---------------------------------------------------------------------
1401 # Loads the circ policy info for duration, recurring fine, and max
1402 # fine based on the current copy
1403 # ---------------------------------------------------------------------
1404 sub get_circ_policy {
1405     my($self, $duration_rule, $recurring_fine_rule, $max_fine_rule, $hard_due_date) = @_;
1406
1407     my $policy = {
1408         duration_rule => $duration_rule->name,
1409         recurring_fine_rule => $recurring_fine_rule->name,
1410         max_fine_rule => $max_fine_rule->name,
1411         max_fine => $self->get_max_fine_amount($max_fine_rule),
1412         fine_interval => $recurring_fine_rule->recurrence_interval,
1413         renewal_remaining => $duration_rule->max_renewals,
1414         auto_renewal_remaining => $duration_rule->max_auto_renewals,
1415         grace_period => $recurring_fine_rule->grace_period
1416     };
1417
1418     if($hard_due_date) {
1419         $policy->{duration_date_ceiling} = $hard_due_date->ceiling_date;
1420         $policy->{duration_date_ceiling_force} = $hard_due_date->forceto;
1421     }
1422     else {
1423         $policy->{duration_date_ceiling} = undef;
1424         $policy->{duration_date_ceiling_force} = undef;
1425     }
1426
1427     $policy->{duration} = $duration_rule->shrt
1428         if $self->copy->loan_duration == OILS_CIRC_DURATION_SHORT;
1429     $policy->{duration} = $duration_rule->normal
1430         if $self->copy->loan_duration == OILS_CIRC_DURATION_NORMAL;
1431     $policy->{duration} = $duration_rule->extended
1432         if $self->copy->loan_duration == OILS_CIRC_DURATION_EXTENDED;
1433
1434     $policy->{recurring_fine} = $recurring_fine_rule->low
1435         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_LOW;
1436     $policy->{recurring_fine} = $recurring_fine_rule->normal
1437         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_NORMAL;
1438     $policy->{recurring_fine} = $recurring_fine_rule->high
1439         if $self->copy->fine_level == OILS_REC_FINE_LEVEL_HIGH;
1440
1441     return $policy;
1442 }
1443
1444 sub get_max_fine_amount {
1445     my $self = shift;
1446     my $max_fine_rule = shift;
1447     my $max_amount = $max_fine_rule->amount;
1448
1449     # if is_percent is true then the max->amount is
1450     # use as a percentage of the copy price
1451     if ($U->is_true($max_fine_rule->is_percent)) {
1452         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1453         $max_amount = $price * $max_fine_rule->amount / 100;
1454     } elsif (
1455         $U->ou_ancestor_setting_value(
1456             $self->circ_lib,
1457             'circ.max_fine.cap_at_price',
1458             $self->editor
1459         )
1460     ) {
1461         my $price = $U->get_copy_price($self->editor, $self->copy, $self->volume);
1462         $max_amount = ( $price && $max_amount > $price ) ? $price : $max_amount;
1463     }
1464
1465     return $max_amount;
1466 }
1467
1468
1469
1470 sub run_copy_permit_scripts {
1471     my $self = shift;
1472     my $copy = $self->copy || return;
1473
1474     my @allevents;
1475
1476     my $results = $self->run_indb_circ_test;
1477     push @allevents, $self->matrix_test_result_events
1478         unless $self->circ_test_success;
1479
1480     # See if this copy has an alert message
1481     my $ae = $self->check_copy_alert();
1482     push( @allevents, $ae ) if $ae;
1483
1484     # uniquify the events
1485     my %hash = map { ($_->{ilsevent} => $_) } @allevents;
1486     @allevents = values %hash;
1487
1488     $logger->info("circulator: permit_copy script returned events: @allevents") if @allevents;
1489
1490     $self->push_events(@allevents);
1491 }
1492
1493
1494 sub check_copy_alert {
1495     my $self = shift;
1496
1497     if ($self->new_copy_alerts) {
1498         my @alerts;
1499         push @alerts, @{$self->user_copy_alerts} # we have preexisting alerts 
1500             if ($self->user_copy_alerts && @{$self->user_copy_alerts});
1501
1502         push @alerts, @{$self->system_copy_alerts} # we have new dynamic alerts 
1503             if ($self->system_copy_alerts && @{$self->system_copy_alerts});
1504
1505         if (@alerts) {
1506             $self->bail_out(1) if (!$self->override);
1507             return OpenILS::Event->new( 'COPY_ALERT_MESSAGE', payload => \@alerts);
1508         }
1509     }
1510
1511     return undef if $self->is_renewal;
1512     return OpenILS::Event->new(
1513         'COPY_ALERT_MESSAGE', payload => $self->copy->alert_message)
1514         if $self->copy and $self->copy->alert_message;
1515     return undef;
1516 }
1517
1518
1519
1520 # --------------------------------------------------------------------------
1521 # If the call is overriding and has permissions to override every collected
1522 # event, the are cleared.  Any event that the caller does not have
1523 # permission to override, will be left in the event list and bail_out will
1524 # be set
1525 # XXX We need code in here to cancel any holds/transits on copies 
1526 # that are being force-checked out
1527 # --------------------------------------------------------------------------
1528 sub override_events {
1529     my $self = shift;
1530     my @events = @{$self->events};
1531     return unless @events;
1532     my $oargs = $self->override_args;
1533
1534     if(!$self->override) {
1535         return $self->bail_out(1) 
1536             if( @events > 1 or $events[0]->{textcode} ne 'SUCCESS' );
1537     }   
1538
1539     $self->events([]);
1540     
1541     for my $e (@events) {
1542         my $tc = $e->{textcode};
1543         next if $tc eq 'SUCCESS';
1544         if($oargs->{all} || grep { $_ eq $tc } @{$oargs->{events}}) {
1545             my $ov = "$tc.override";
1546             $logger->info("circulator: attempting to override event: $ov");
1547
1548             return $self->bail_on_events($self->editor->event)
1549                 unless( $self->editor->allowed($ov) );
1550         } else {
1551             return $self->bail_out(1);
1552         }
1553    }
1554 }
1555     
1556
1557 # --------------------------------------------------------------------------
1558 # If there is an open claimsreturn circ on the requested copy, close the 
1559 # circ if overriding, otherwise bail out
1560 # --------------------------------------------------------------------------
1561 sub handle_claims_returned {
1562     my $self = shift;
1563     my $copy = $self->copy;
1564
1565     my $CR = $self->editor->search_action_circulation(
1566         {   
1567             target_copy     => $copy->id,
1568             stop_fines      => OILS_STOP_FINES_CLAIMSRETURNED,
1569             checkin_time    => undef,
1570         }
1571     );
1572
1573     return unless ($CR = $CR->[0]); 
1574
1575     my $evt;
1576
1577     # - If the caller has set the override flag, we will check the item in
1578     if($self->override && ($self->override_args->{all} || grep { $_ eq 'CIRC_CLAIMS_RETURNED' } @{$self->override_args->{events}}) ) {
1579
1580         $CR->checkin_time('now');   
1581         $CR->checkin_scan_time('now');   
1582         $CR->checkin_lib($self->circ_lib);
1583         $CR->checkin_workstation($self->editor->requestor->wsid);
1584         $CR->checkin_staff($self->editor->requestor->id);
1585
1586         $evt = $self->editor->event 
1587             unless $self->editor->update_action_circulation($CR);
1588
1589     } else {
1590         $evt = OpenILS::Event->new('CIRC_CLAIMS_RETURNED');
1591     }
1592
1593     $self->bail_on_events($evt) if $evt;
1594     return;
1595 }
1596
1597
1598 # --------------------------------------------------------------------------
1599 # This performs the checkout
1600 # --------------------------------------------------------------------------
1601 sub do_checkout {
1602     my $self = shift;
1603
1604     $self->log_me("do_checkout()");
1605
1606     # make sure perms are good if this isn't a renewal
1607     unless( $self->is_renewal ) {
1608         return $self->bail_on_events($self->editor->event)
1609             unless( $self->editor->allowed('COPY_CHECKOUT') );
1610     }
1611
1612     # verify the permit key
1613     unless( $self->check_permit_key ) {
1614         if( $self->permit_override ) {
1615             return $self->bail_on_events($self->editor->event)
1616                 unless $self->editor->allowed('CIRC_PERMIT_OVERRIDE');
1617         } else {
1618             return $self->bail_on_events(OpenILS::Event->new('CIRC_PERMIT_BAD_KEY'))
1619         }   
1620     }
1621
1622     # if this is a non-cataloged circ, build the circ and finish
1623     if( $self->is_noncat ) {
1624         $self->checkout_noncat;
1625         $self->push_events(
1626             OpenILS::Event->new('SUCCESS', 
1627             payload => { noncat_circ => $self->circ }));
1628         return;
1629     }
1630
1631     if( $self->is_precat ) {
1632         $self->make_precat_copy;
1633         return if $self->bail_out;
1634
1635     } elsif( $self->copy->call_number == OILS_PRECAT_CALL_NUMBER ) {
1636         return $self->bail_on_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
1637     }
1638
1639     $self->do_copy_checks;
1640     return if $self->bail_out;
1641
1642     $self->run_checkout_scripts();
1643     return if $self->bail_out;
1644
1645     $self->build_checkout_circ_object();
1646     return if $self->bail_out;
1647
1648     my $modify_to_start = $self->booking_adjusted_due_date();
1649     return if $self->bail_out;
1650
1651     $self->apply_modified_due_date($modify_to_start);
1652     return if $self->bail_out;
1653
1654     return $self->bail_on_events($self->editor->event)
1655         unless $self->editor->create_action_circulation($self->circ);
1656
1657     # refresh the circ to force local time zone for now
1658     $self->circ($self->editor->retrieve_action_circulation($self->circ->id));
1659
1660     if($self->limit_groups) {
1661         $self->editor->json_query({ from => ['action.link_circ_limit_groups', $self->circ->id, $self->limit_groups] });
1662     }
1663
1664     $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
1665     $self->update_copy;
1666     return if $self->bail_out;
1667
1668     $self->apply_deposit_fee();
1669     return if $self->bail_out;
1670
1671     $self->handle_checkout_holds();
1672     return if $self->bail_out;
1673
1674     # ------------------------------------------------------------------------------
1675     # Update the patron penalty info in the DB.  Run it for permit-overrides 
1676     # since the penalties are not updated during the permit phase
1677     # ------------------------------------------------------------------------------
1678     OpenILS::Utils::Penalty->calculate_penalties($self->editor, $self->patron->id, $self->circ_lib);
1679
1680     my $record = $U->record_to_mvr($self->title) unless $self->is_precat;
1681     
1682     my $pcirc;
1683     if($self->is_renewal) {
1684         # flesh the billing summary for the checked-in circ
1685         $pcirc = $self->editor->retrieve_action_circulation([
1686             $self->parent_circ,
1687             {flesh => 2, flesh_fields => {circ => ['billable_transaction'], mbt => ['summary']}}
1688         ]);
1689     }
1690
1691     $self->push_events(
1692         OpenILS::Event->new('SUCCESS',
1693             payload  => {
1694                 copy             => $U->unflesh_copy($self->copy),
1695                 volume           => $self->volume,
1696                 circ             => $self->circ,
1697                 record           => $record,
1698                 holds_fulfilled  => $self->fulfilled_holds,
1699                 deposit_billing  => $self->deposit_billing,
1700                 rental_billing   => $self->rental_billing,
1701                 parent_circ      => $pcirc,
1702                 patron           => ($self->return_patron) ? $self->patron : undef,
1703                 patron_money     => $self->editor->retrieve_money_user_summary($self->patron->id)
1704             }
1705         )
1706     );
1707 }
1708
1709 sub apply_deposit_fee {
1710     my $self = shift;
1711     my $copy = $self->copy;
1712     return unless 
1713         ($self->is_deposit and not $self->is_deposit_exempt) or 
1714         ($self->is_rental and not $self->is_rental_exempt);
1715
1716     return if $self->is_deposit and $self->skip_deposit_fee;
1717     return if $self->is_rental and $self->skip_rental_fee;
1718
1719     my $bill = Fieldmapper::money::billing->new;
1720     my $amount = $copy->deposit_amount;
1721     my $billing_type;
1722     my $btype;
1723
1724     if($self->is_deposit) {
1725         $billing_type = OILS_BILLING_TYPE_DEPOSIT;
1726         $btype = 5;
1727         $self->deposit_billing($bill);
1728     } else {
1729         $billing_type = OILS_BILLING_TYPE_RENTAL;
1730         $btype = 6;
1731         $self->rental_billing($bill);
1732     }
1733
1734     $bill->xact($self->circ->id);
1735     $bill->amount($amount);
1736     $bill->note(OILS_BILLING_NOTE_SYSTEM);
1737     $bill->billing_type($billing_type);
1738     $bill->btype($btype);
1739     $self->editor->create_money_billing($bill) or $self->bail_on_events($self->editor->event);
1740
1741     $logger->info("circulator: charged $amount on checkout with billing type $billing_type");
1742 }
1743
1744 sub update_copy {
1745     my $self = shift;
1746     my $copy = $self->copy;
1747
1748     my $stat = $copy->status if ref $copy->status;
1749     my $loc = $copy->location if ref $copy->location;
1750     my $circ_lib = $copy->circ_lib if ref $copy->circ_lib;
1751
1752     $copy->status($stat->id) if $stat;
1753     $copy->location($loc->id) if $loc;
1754     $copy->circ_lib($circ_lib->id) if $circ_lib;
1755     $copy->editor($self->editor->requestor->id);
1756     $copy->edit_date('now');
1757     $copy->age_protect($copy->age_protect->id) if ref $copy->age_protect;
1758
1759     return $self->bail_on_events($self->editor->event)
1760         unless $self->editor->update_asset_copy($self->copy);
1761
1762     $copy->status($U->copy_status($copy->status));
1763     $copy->location($loc) if $loc;
1764     $copy->circ_lib($circ_lib) if $circ_lib;
1765 }
1766
1767 sub update_reservation {
1768     my $self = shift;
1769     my $reservation = $self->reservation;
1770
1771     my $usr = $reservation->usr;
1772     my $target_rt = $reservation->target_resource_type;
1773     my $target_r = $reservation->target_resource;
1774     my $current_r = $reservation->current_resource;
1775
1776     $reservation->usr($usr->id) if ref $usr;
1777     $reservation->target_resource_type($target_rt->id) if ref $target_rt;
1778     $reservation->target_resource($target_r->id) if ref $target_r;
1779     $reservation->current_resource($current_r->id) if ref $current_r;
1780
1781     return $self->bail_on_events($self->editor->event)
1782         unless $self->editor->update_booking_reservation($self->reservation);
1783
1784     my $evt;
1785     ($reservation, $evt) = $U->fetch_booking_reservation($reservation->id);
1786     $self->reservation($reservation);
1787 }
1788
1789
1790 sub bail_on_events {
1791     my( $self, @evts ) = @_;
1792     $self->push_events(@evts);
1793     $self->bail_out(1);
1794 }
1795
1796 # ------------------------------------------------------------------------------
1797 # A hold FULFILL block is just like a CIRC block, except that FULFILL only
1798 # affects copies that will fulfill holds and CIRC affects all other copies.
1799 # If blocks exists, bail, push Events onto the event pile, and return true.
1800 # ------------------------------------------------------------------------------
1801 sub check_hold_fulfill_blocks {
1802     my $self = shift;
1803
1804     # With the addition of ignore_proximity in csp, we need to fetch
1805     # the proximity of both the circ_lib and the copy's circ_lib to
1806     # the patron's home_ou.
1807     my ($ou_prox, $copy_prox);
1808     my $home_ou = (ref($self->patron->home_ou)) ? $self->patron->home_ou->id : $self->patron->home_ou;
1809     $ou_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $self->circ_lib);
1810     $ou_prox = -1 unless (defined($ou_prox));
1811     my $copy_ou = (ref($self->copy->circ_lib)) ? $self->copy->circ_lib->id : $self->copy->circ_lib;
1812     if ($copy_ou == $self->circ_lib) {
1813         # Save us the time of an extra query.
1814         $copy_prox = $ou_prox;
1815     } else {
1816         $copy_prox = $U->get_org_unit_proximity($self->editor, $home_ou, $copy_ou);
1817         $copy_prox = -1 unless (defined($copy_prox));
1818     }
1819
1820     # See if the user has any penalties applied that prevent hold fulfillment
1821     my $pens = $self->editor->json_query({
1822         select => {csp => ['name', 'label']},
1823         from => {ausp => {csp => {}}},
1824         where => {
1825             '+ausp' => {
1826                 usr => $self->patron->id,
1827                 org_unit => $U->get_org_full_path($self->circ_lib),
1828                 '-or' => [
1829                     {stop_date => undef},
1830                     {stop_date => {'>' => 'now'}}
1831                 ]
1832             },
1833             '+csp' => {
1834                 block_list => {'like' => '%FULFILL%'},
1835                 '-or' => [
1836                     {ignore_proximity => undef},
1837                     {ignore_proximity => {'<' => $ou_prox}},
1838                     {ignore_proximity => {'<' => $copy_prox}}
1839                 ]
1840             }
1841         }
1842     });
1843
1844     return 0 unless @$pens;
1845
1846     for my $pen (@$pens) {
1847         $logger->info("circulator: patron has hold FULFILL block " . $pen->{name});
1848         my $event = OpenILS::Event->new($pen->{name});
1849         $event->{desc} = $pen->{label};
1850         $self->push_events($event);
1851     }
1852
1853     $self->override_events;
1854     return $self->bail_out;
1855 }
1856
1857
1858 # ------------------------------------------------------------------------------
1859 # When an item is checked out, see if we can fulfill a hold for this patron
1860 # ------------------------------------------------------------------------------
1861 sub handle_checkout_holds {
1862    my $self    = shift;
1863    my $copy    = $self->copy;
1864    my $patron  = $self->patron;
1865
1866    my $e = $self->editor;
1867    $self->fulfilled_holds([]);
1868
1869    # non-cats can't fulfill a hold
1870    return if $self->is_noncat;
1871
1872     my $hold = $e->search_action_hold_request({   
1873         current_copy        => $copy->id , 
1874         cancel_time         => undef, 
1875         fulfillment_time    => undef
1876     })->[0];
1877
1878     if($hold and $hold->usr != $patron->id) {
1879         # reset the hold since the copy is now checked out
1880     
1881         $logger->info("circulator: un-targeting hold ".$hold->id.
1882             " because copy ".$copy->id." is getting checked out");
1883
1884         $hold->clear_prev_check_time; 
1885         $hold->clear_current_copy;
1886         $hold->clear_capture_time;
1887         $hold->clear_shelf_time;
1888         $hold->clear_shelf_expire_time;
1889         $hold->clear_current_shelf_lib;
1890
1891         return $self->bail_on_event($e->event)
1892             unless $e->update_action_hold_request($hold);
1893
1894         $hold = undef;
1895     }
1896
1897     unless($hold) {
1898         $hold = $self->find_related_user_hold($copy, $patron) or return;
1899         $logger->info("circulator: found related hold to fulfill in checkout");
1900     }
1901
1902     return if $self->check_hold_fulfill_blocks;
1903
1904     $logger->debug("circulator: checkout fulfilling hold " . $hold->id);
1905
1906     # if the hold was never officially captured, capture it.
1907     $hold->current_copy($copy->id);
1908     $hold->capture_time('now') unless $hold->capture_time;
1909     $hold->fulfillment_time('now');
1910     $hold->fulfillment_staff($e->requestor->id);
1911     $hold->fulfillment_lib($self->circ_lib);
1912
1913     return $self->bail_on_events($e->event)
1914         unless $e->update_action_hold_request($hold);
1915
1916     return $self->fulfilled_holds([$hold->id]);
1917 }
1918
1919
1920 # ------------------------------------------------------------------------------
1921 # If the circ.checkout_fill_related_hold setting is turned on and no hold for
1922 # the patron directly targets the checked out item, see if there is another hold 
1923 # for the patron that could be fulfilled by the checked out item.  Fulfill the
1924 # oldest hold and only fulfill 1 of them.
1925
1926 # For "another hold":
1927 #
1928 # First, check for one that the copy matches via hold_copy_map, ensuring that
1929 # *any* hold type that this copy could fill may end up filled.
1930 #
1931 # Then, if circ.checkout_fill_related_hold_exact_match_only is not enabled, look
1932 # for a Title (T) or Volume (V) hold that matches the item. This allows items
1933 # that are non-requestable to count as capturing those hold types.
1934 # ------------------------------------------------------------------------------
1935 sub find_related_user_hold {
1936     my($self, $copy, $patron) = @_;
1937     my $e = $self->editor;
1938
1939     # holds on precat copies are always copy-level, so this call will
1940     # always return undef.  Exit early.
1941     return undef if $self->is_precat;
1942
1943     return undef unless $U->ou_ancestor_setting_value(        
1944         $self->circ_lib, 'circ.checkout_fills_related_hold', $e);
1945
1946     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1947     my $args = {
1948         select => {ahr => ['id']}, 
1949         from => {
1950             ahr => {
1951                 ahcm => {
1952                     field => 'hold',
1953                     fkey => 'id'
1954                 },
1955                 acp => {
1956                     field => 'id', 
1957                     fkey => 'current_copy',
1958                     type => 'left' # there may be no current_copy
1959                 }
1960             }
1961         }, 
1962         where => {
1963             '+ahr' => {
1964                 usr => $patron->id,
1965                 fulfillment_time => undef,
1966                 cancel_time => undef,
1967                '-or' => [
1968                     {expire_time => undef},
1969                     {expire_time => {'>' => 'now'}}
1970                 ]
1971             },
1972             '+ahcm' => {
1973                 target_copy => $self->copy->id
1974             },
1975             '+acp' => {
1976                 '-or' => [
1977                     {id => undef}, # left-join copy may be nonexistent
1978                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
1979                 ]
1980             }
1981         },
1982         order_by => {ahr => {request_time => {direction => 'asc'}}},
1983         limit => 1
1984     };
1985
1986     my $hold_info = $e->json_query($args)->[0];
1987     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
1988     return undef if $U->ou_ancestor_setting_value(        
1989         $self->circ_lib, 'circ.checkout_fills_related_hold_exact_match_only', $e);
1990
1991     # find the oldest unfulfilled hold that has not yet hit the holds shelf.
1992     $args = {
1993         select => {ahr => ['id']}, 
1994         from => {
1995             ahr => {
1996                 acp => {
1997                     field => 'id', 
1998                     fkey => 'current_copy',
1999                     type => 'left' # there may be no current_copy
2000                 }
2001             }
2002         }, 
2003         where => {
2004             '+ahr' => {
2005                 usr => $patron->id,
2006                 fulfillment_time => undef,
2007                 cancel_time => undef,
2008                '-or' => [
2009                     {expire_time => undef},
2010                     {expire_time => {'>' => 'now'}}
2011                 ]
2012             },
2013             '-or' => [
2014                 {
2015                     '+ahr' => { 
2016                         hold_type => 'V',
2017                         target => $self->volume->id
2018                     }
2019                 },
2020                 { 
2021                     '+ahr' => { 
2022                         hold_type => 'T',
2023                         target => $self->title->id
2024                     }
2025                 },
2026             ],
2027             '+acp' => {
2028                 '-or' => [
2029                     {id => undef}, # left-join copy may be nonexistent
2030                     {status => {'!=' => OILS_COPY_STATUS_ON_HOLDS_SHELF}},
2031                 ]
2032             }
2033         },
2034         order_by => {ahr => {request_time => {direction => 'asc'}}},
2035         limit => 1
2036     };
2037
2038     $hold_info = $e->json_query($args)->[0];
2039     return $e->retrieve_action_hold_request($hold_info->{id}) if $hold_info;
2040     return undef;
2041 }
2042
2043
2044 sub run_checkout_scripts {
2045     my $self = shift;
2046     my $nobail = shift;
2047
2048     my $evt;
2049
2050     my $duration;
2051     my $recurring;
2052     my $max_fine;
2053     my $hard_due_date;
2054     my $duration_name;
2055     my $recurring_name;
2056     my $max_fine_name;
2057     my $hard_due_date_name;
2058
2059     $self->run_indb_circ_test();
2060     $duration = $self->circ_matrix_matchpoint->duration_rule;
2061     $recurring = $self->circ_matrix_matchpoint->recurring_fine_rule;
2062     $max_fine = $self->circ_matrix_matchpoint->max_fine_rule;
2063     $hard_due_date = $self->circ_matrix_matchpoint->hard_due_date;
2064
2065     $duration_name = $duration->name if $duration;
2066     if( $duration_name ne OILS_UNLIMITED_CIRC_DURATION ) {
2067
2068         unless($duration) {
2069             ($duration, $evt) = $U->fetch_circ_duration_by_name($duration_name);
2070             return $self->bail_on_events($evt) if ($evt && !$nobail);
2071         
2072             ($recurring, $evt) = $U->fetch_recurring_fine_by_name($recurring_name);
2073             return $self->bail_on_events($evt) if ($evt && !$nobail);
2074         
2075             ($max_fine, $evt) = $U->fetch_max_fine_by_name($max_fine_name);
2076             return $self->bail_on_events($evt) if ($evt && !$nobail);
2077
2078             if($hard_due_date_name) {
2079                 ($hard_due_date, $evt) = $U->fetch_hard_due_date_by_name($hard_due_date_name);
2080                 return $self->bail_on_events($evt) if ($evt && !$nobail);
2081             }
2082         }
2083
2084     } else {
2085
2086         # The item circulates with an unlimited duration
2087         $duration   = undef;
2088         $recurring  = undef;
2089         $max_fine   = undef;
2090         $hard_due_date = undef;
2091     }
2092
2093    $self->duration_rule($duration);
2094    $self->recurring_fines_rule($recurring);
2095    $self->max_fine_rule($max_fine);
2096    $self->hard_due_date($hard_due_date);
2097 }
2098
2099
2100 sub build_checkout_circ_object {
2101     my $self = shift;
2102
2103    my $circ       = Fieldmapper::action::circulation->new;
2104    my $duration   = $self->duration_rule;
2105    my $max        = $self->max_fine_rule;
2106    my $recurring  = $self->recurring_fines_rule;
2107    my $hard_due_date    = $self->hard_due_date;
2108    my $copy       = $self->copy;
2109    my $patron     = $self->patron;
2110    my $duration_date_ceiling;
2111    my $duration_date_ceiling_force;
2112
2113     if( $duration ) {
2114
2115         my $policy = $self->get_circ_policy($duration, $recurring, $max, $hard_due_date);
2116         $duration_date_ceiling = $policy->{duration_date_ceiling};
2117         $duration_date_ceiling_force = $policy->{duration_date_ceiling_force};
2118
2119         my $dname = $duration->name;
2120         my $mname = $max->name;
2121         my $rname = $recurring->name;
2122         my $hdname = ''; 
2123         if($hard_due_date) {
2124             $hdname = $hard_due_date->name;
2125         }
2126
2127         $logger->debug("circulator: building circulation ".
2128             "with duration=$dname, maxfine=$mname, recurring=$rname, hard due date=$hdname");
2129     
2130         $circ->duration($policy->{duration});
2131         $circ->recurring_fine($policy->{recurring_fine});
2132         $circ->duration_rule($duration->name);
2133         $circ->recurring_fine_rule($recurring->name);
2134         $circ->max_fine_rule($max->name);
2135         $circ->max_fine($policy->{max_fine});
2136         $circ->fine_interval($recurring->recurrence_interval);
2137         $circ->renewal_remaining($duration->max_renewals);
2138         $circ->auto_renewal_remaining($duration->max_auto_renewals);
2139         $circ->grace_period($policy->{grace_period});
2140
2141     } else {
2142
2143         $logger->info("circulator: copy found with an unlimited circ duration");
2144         $circ->duration_rule(OILS_UNLIMITED_CIRC_DURATION);
2145         $circ->recurring_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2146         $circ->max_fine_rule(OILS_UNLIMITED_CIRC_DURATION);
2147         $circ->renewal_remaining(0);
2148         $circ->grace_period(0);
2149     }
2150
2151    $circ->target_copy( $copy->id );
2152    $circ->usr( $patron->id );
2153    $circ->circ_lib( $self->circ_lib );
2154    $circ->workstation($self->editor->requestor->wsid) 
2155     if defined $self->editor->requestor->wsid;
2156
2157     # renewals maintain a link to the parent circulation
2158     $circ->parent_circ($self->parent_circ);
2159
2160    if( $self->is_renewal ) {
2161       $circ->opac_renewal('t') if $self->opac_renewal;
2162       $circ->phone_renewal('t') if $self->phone_renewal;
2163       $circ->desk_renewal('t') if $self->desk_renewal;
2164       $circ->auto_renewal('t') if $self->auto_renewal;
2165       $circ->renewal_remaining($self->renewal_remaining);
2166       $circ->auto_renewal_remaining($self->auto_renewal_remaining);
2167       $circ->circ_staff($self->editor->requestor->id);
2168    }
2169
2170     # if the user provided an overiding checkout time,
2171     # (e.g. the checkout really happened several hours ago), then
2172     # we apply that here.  Does this need a perm??
2173     $circ->xact_start(clean_ISO8601($self->checkout_time))
2174         if $self->checkout_time;
2175
2176     # if a patron is renewing, 'requestor' will be the patron
2177     $circ->circ_staff($self->editor->requestor->id);
2178     $circ->due_date( $self->create_due_date($circ->duration, $duration_date_ceiling, $duration_date_ceiling_force, $circ->xact_start) ) if $circ->duration;
2179
2180     $self->circ($circ);
2181 }
2182
2183 sub do_reservation_pickup {
2184     my $self = shift;
2185
2186     $self->log_me("do_reservation_pickup()");
2187
2188     $self->reservation->pickup_time('now');
2189
2190     if (
2191         $self->reservation->current_resource &&
2192         $U->is_true($self->reservation->target_resource_type->catalog_item)
2193     ) {
2194         # We used to try to set $self->copy and $self->patron here,
2195         # but that should already be done.
2196
2197         $self->run_checkout_scripts(1);
2198
2199         my $duration   = $self->duration_rule;
2200         my $max        = $self->max_fine_rule;
2201         my $recurring  = $self->recurring_fines_rule;
2202
2203         if ($duration && $max && $recurring) {
2204             my $policy = $self->get_circ_policy($duration, $recurring, $max);
2205
2206             my $dname = $duration->name;
2207             my $mname = $max->name;
2208             my $rname = $recurring->name;
2209
2210             $logger->debug("circulator: updating reservation ".
2211                 "with duration=$dname, maxfine=$mname, recurring=$rname");
2212
2213             $self->reservation->fine_amount($policy->{recurring_fine});
2214             $self->reservation->max_fine($policy->{max_fine});
2215             $self->reservation->fine_interval($recurring->recurrence_interval);
2216         }
2217
2218         $self->copy->status(OILS_COPY_STATUS_CHECKED_OUT);
2219         $self->update_copy();
2220
2221     } else {
2222         $self->reservation->fine_amount(
2223             $self->reservation->target_resource_type->fine_amount
2224         );
2225         $self->reservation->max_fine(
2226             $self->reservation->target_resource_type->max_fine
2227         );
2228         $self->reservation->fine_interval(
2229             $self->reservation->target_resource_type->fine_interval
2230         );
2231     }
2232
2233     $self->update_reservation();
2234 }
2235
2236 sub do_reservation_return {
2237     my $self = shift;
2238     my $request = shift;
2239
2240     $self->log_me("do_reservation_return()");
2241
2242     if (not ref $self->reservation) {
2243         my ($reservation, $evt) =
2244             $U->fetch_booking_reservation($self->reservation);
2245         return $self->bail_on_events($evt) if $evt;
2246         $self->reservation($reservation);
2247     }
2248
2249     $self->handle_fines(1);
2250     $self->reservation->return_time('now');
2251     $self->update_reservation();
2252     $self->reshelve_copy if $self->copy;
2253
2254     if ( $self->reservation->current_resource && $self->reservation->current_resource->catalog_item ) {
2255         $self->copy( $self->reservation->current_resource->catalog_item );
2256     }
2257 }
2258
2259 sub booking_adjusted_due_date {
2260     my $self = shift;
2261     my $circ = $self->circ;
2262     my $copy = $self->copy;
2263
2264     return undef unless $self->use_booking;
2265
2266     my $changed;
2267
2268     if( $self->due_date ) {
2269
2270         return $self->bail_on_events($self->editor->event)
2271             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2272
2273        $circ->due_date(clean_ISO8601($self->due_date));
2274
2275     } else {
2276
2277         return unless $copy and $circ->due_date;
2278     }
2279
2280     my $booking_items = $self->editor->search_booking_resource( { barcode => $copy->barcode } );
2281     if (@$booking_items) {
2282         my $booking_item = $booking_items->[0];
2283         my $resource_type = $self->editor->retrieve_booking_resource_type( $booking_item->type );
2284
2285         my $stop_circ_setting = $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.stop_circ', $self->editor );
2286         my $shorten_circ_setting = $resource_type->elbow_room ||
2287             $U->ou_ancestor_setting_value( $self->circ_lib, 'circ.booking_reservation.default_elbow_room', $self->editor ) ||
2288             '0 seconds';
2289
2290         my $booking_ses = OpenSRF::AppSession->create( 'open-ils.booking' );
2291         my $bookings = $booking_ses->request('open-ils.booking.reservations.filtered_id_list', $self->editor->authtoken, {
2292               resource     => $booking_item->id
2293             , search_start => 'now'
2294             , search_end   => $circ->due_date
2295             , fields       => { cancel_time => undef, return_time => undef }
2296         })->gather(1);
2297         $booking_ses->disconnect;
2298
2299         throw OpenSRF::EX::ERROR ("Improper input arguments") unless defined $bookings;
2300         return $self->bail_on_events($bookings) if ref($bookings) eq 'HASH';
2301         
2302         my $dt_parser = DateTime::Format::ISO8601->new;
2303         my $due_date = $dt_parser->parse_datetime( clean_ISO8601($circ->due_date) );
2304
2305         for my $bid (@$bookings) {
2306
2307             my $booking = $self->editor->retrieve_booking_reservation( $bid );
2308
2309             my $booking_start = $dt_parser->parse_datetime( clean_ISO8601($booking->start_time) );
2310             my $booking_end = $dt_parser->parse_datetime( clean_ISO8601($booking->end_time) );
2311
2312             return $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') )
2313                 if ($booking_start < DateTime->now);
2314
2315
2316             if ($U->is_true($stop_circ_setting)) {
2317                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ); 
2318             } else {
2319                 $due_date = $booking_start->subtract( seconds => interval_to_seconds($shorten_circ_setting) );
2320                 $self->bail_on_events( OpenILS::Event->new('COPY_RESERVED') ) if ($due_date < DateTime->now); 
2321             }
2322             
2323             # We set the circ duration here only to affect the logic that will
2324             # later (in a DB trigger) mangle the time part of the due date to
2325             # 11:59pm. Having any circ duration that is not a whole number of
2326             # days is enough to prevent the "correction."
2327             my $new_circ_duration = $due_date->epoch - time;
2328             $new_circ_duration++ if $new_circ_duration % 86400 == 0;
2329             $circ->duration("$new_circ_duration seconds");
2330
2331             $circ->due_date(clean_ISO8601($due_date->strftime('%FT%T%z')));
2332             $changed = 1;
2333         }
2334
2335         return $self->bail_on_events($self->editor->event)
2336             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2337     }
2338
2339     return $changed;
2340 }
2341
2342 sub apply_modified_due_date {
2343     my $self = shift;
2344     my $shift_earlier = shift;
2345     my $circ = $self->circ;
2346     my $copy = $self->copy;
2347
2348    if( $self->due_date ) {
2349
2350         return $self->bail_on_events($self->editor->event)
2351             unless $self->editor->allowed('CIRC_OVERRIDE_DUE_DATE', $self->circ_lib);
2352
2353       $circ->due_date(clean_ISO8601($self->due_date));
2354
2355    } else {
2356
2357       # if the due_date lands on a day when the location is closed
2358       return unless $copy and $circ->due_date;
2359
2360         #my $org = (ref $copy->circ_lib) ? $copy->circ_lib->id : $copy->circ_lib;
2361
2362         # due-date overlap should be determined by the location the item
2363         # is checked out from, not the owning or circ lib of the item
2364         my $org = $self->circ_lib;
2365
2366       $logger->info("circulator: circ searching for closed date overlap on lib $org".
2367             " with an item due date of ".$circ->due_date );
2368
2369       my $dateinfo = $U->storagereq(
2370          'open-ils.storage.actor.org_unit.closed_date.overlap', 
2371             $org, $circ->due_date );
2372
2373       if($dateinfo) {
2374          $logger->info("circulator: $dateinfo : circ due data / close date overlap found : due_date=".
2375             $circ->due_date." start=". $dateinfo->{start}.", end=".$dateinfo->{end});
2376
2377             # XXX make the behavior more dynamic
2378             # for now, we just push the due date to after the close date
2379             if ($shift_earlier) {
2380                 $circ->due_date($dateinfo->{start});
2381             } else {
2382                 $circ->due_date($dateinfo->{end});
2383             }
2384       }
2385    }
2386 }
2387
2388
2389
2390 sub create_due_date {
2391     my( $self, $duration, $date_ceiling, $force_date, $start_time ) = @_;
2392
2393     # Look up circulating library's TZ, or else use client TZ, falling
2394     # back to server TZ
2395     my $tz = $U->ou_ancestor_setting_value(
2396         $self->circ_lib,
2397         'lib.timezone',
2398         $self->editor
2399     ) || 'local';
2400
2401     my $due_date = $start_time ?
2402         DateTime::Format::ISO8601
2403             ->new
2404             ->parse_datetime(clean_ISO8601($start_time))
2405             ->set_time_zone($tz) :
2406         DateTime->now(time_zone => $tz);
2407
2408     # add the circ duration
2409     $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($duration, $due_date));
2410
2411     if($date_ceiling) {
2412         my $cdate = DateTime::Format::ISO8601
2413             ->new
2414             ->parse_datetime(clean_ISO8601($date_ceiling))
2415             ->set_time_zone($tz);
2416
2417         if ($cdate > DateTime->now and ($cdate < $due_date or $U->is_true( $force_date ))) {
2418             $logger->info("circulator: overriding due date with date ceiling: $date_ceiling");
2419             $due_date = $cdate;
2420         }
2421     }
2422
2423     # return ISO8601 time with timezone
2424     return $due_date->strftime('%FT%T%z');
2425 }
2426
2427
2428
2429 sub make_precat_copy {
2430     my $self = shift;
2431     my $copy = $self->copy;
2432     return $self->bail_on_events(OpenILS::Event->new('PERM_FAILURE'))
2433        unless $self->editor->allowed('CREATE_PRECAT') || $self->is_renewal;
2434
2435    if($copy) {
2436         $logger->debug("circulator: Pre-cat copy already exists in checkout: ID=" . $copy->id);
2437
2438         $copy->editor($self->editor->requestor->id);
2439         $copy->edit_date('now');
2440         $copy->dummy_title($self->dummy_title || $copy->dummy_title || '');
2441         $copy->dummy_isbn($self->dummy_isbn || $copy->dummy_isbn || '');
2442         $copy->dummy_author($self->dummy_author || $copy->dummy_author || '');
2443         $copy->circ_modifier($self->circ_modifier || $copy->circ_modifier);
2444         $self->update_copy();
2445         return;
2446    }
2447
2448     $logger->info("circulator: Creating a new precataloged ".
2449         "copy in checkout with barcode " . $self->copy_barcode);
2450
2451     $copy = Fieldmapper::asset::copy->new;
2452     $copy->circ_lib($self->circ_lib);
2453     $copy->creator($self->editor->requestor->id);
2454     $copy->editor($self->editor->requestor->id);
2455     $copy->barcode($self->copy_barcode);
2456     $copy->call_number(OILS_PRECAT_CALL_NUMBER); 
2457     $copy->loan_duration(OILS_PRECAT_COPY_LOAN_DURATION);
2458     $copy->fine_level(OILS_PRECAT_COPY_FINE_LEVEL);
2459
2460     $copy->dummy_title($self->dummy_title || "");
2461     $copy->dummy_author($self->dummy_author || "");
2462     $copy->dummy_isbn($self->dummy_isbn || "");
2463     $copy->circ_modifier($self->circ_modifier);
2464
2465
2466     # See if we need to override the circ_lib for the copy with a configured circ_lib
2467     # Setting is shortname of the org unit
2468     my $precat_circ_lib = $U->ou_ancestor_setting_value(
2469         $self->circ_lib, 'circ.pre_cat_copy_circ_lib', $self->editor);
2470
2471     if($precat_circ_lib) {
2472         my $org = $self->editor->search_actor_org_unit({shortname => $precat_circ_lib})->[0];
2473
2474         if(!$org) {
2475             $self->bail_on_events($self->editor->event);
2476             return;
2477         }
2478
2479         $copy->circ_lib($org->id);
2480     }
2481
2482
2483     unless( $self->copy($self->editor->create_asset_copy($copy)) ) {
2484         $self->bail_out(1);
2485         $self->push_events($self->editor->event);
2486         return;
2487     }   
2488 }
2489
2490
2491 sub checkout_noncat {
2492     my $self = shift;
2493
2494     my $circ;
2495     my $evt;
2496
2497    my $lib      = $self->noncat_circ_lib || $self->circ_lib;
2498    my $count    = $self->noncat_count || 1;
2499    my $cotime   = clean_ISO8601($self->checkout_time) || "";
2500
2501    $logger->info("circulator: circ creating $count noncat circs with checkout time $cotime");
2502
2503    for(1..$count) {
2504
2505       ( $circ, $evt ) = OpenILS::Application::Circ::NonCat::create_non_cat_circ(
2506          $self->editor->requestor->id, 
2507             $self->patron->id, 
2508             $lib, 
2509             $self->noncat_type, 
2510             $cotime,
2511             $self->editor );
2512
2513         if( $evt ) {
2514             $self->push_events($evt);
2515             $self->bail_out(1);
2516             return; 
2517         }
2518         $self->circ($circ);
2519    }
2520 }
2521
2522 # if an item is in transit but the status doesn't agree, then we need to fix things.
2523 # The next two subs will hopefully do that
2524 sub fix_broken_transit_status {
2525     my $self = shift;
2526
2527     # Capture the transit so we don't have to fetch it again later during checkin
2528     # This used to live in sub check_transit_checkin_interval and later again in
2529     # do_checkin
2530     $self->transit(
2531         $self->editor->search_action_transit_copy(
2532             {target_copy => $self->copy->id, dest_recv_time => undef, cancel_time => undef}
2533         )->[0]
2534     );
2535
2536     if ($self->transit && $U->copy_status($self->copy->status)->id != OILS_COPY_STATUS_IN_TRANSIT) {
2537         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2538             " that is in-transit but without the In Transit status... fixing");
2539         $self->copy->status(OILS_COPY_STATUS_IN_TRANSIT);
2540         # FIXME - do we want to make this permanent if the checkin bails?
2541         $self->update_copy;
2542     }
2543
2544 }
2545 sub cancel_transit_if_circ_exists {
2546     my $self = shift;
2547     if ($self->circ && $self->transit) {
2548         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2549             " that is in-transit AND circulating... aborting the transit");
2550         my $circ_ses = create OpenSRF::AppSession("open-ils.circ");
2551         my $result = $circ_ses->request(
2552             "open-ils.circ.transit.abort",
2553             $self->editor->authtoken,
2554             { 'transitid' => $self->transit->id }
2555         )->gather(1);
2556         $logger->warn("circulator: transit abort result: ".$result);
2557         $circ_ses->disconnect;
2558         $self->transit(undef);
2559     }
2560 }
2561
2562 # If a copy goes into transit and is then checked in before the transit checkin 
2563 # interval has expired, push an event onto the overridable events list.
2564 sub check_transit_checkin_interval {
2565     my $self = shift;
2566
2567     # only concerned with in-transit items
2568     return unless $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT;
2569
2570     # no interval, no problem
2571     my $interval = $U->ou_ancestor_setting_value($self->circ_lib, 'circ.transit.min_checkin_interval');
2572     return unless $interval;
2573
2574     # transit from X to X for whatever reason has no min interval
2575     return if $self->transit->source == $self->transit->dest;
2576
2577     my $seconds = OpenILS::Utils::DateTime->interval_to_seconds($interval);
2578     my $t_start = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->transit->source_send_time));
2579     my $horizon = $t_start->add(seconds => $seconds);
2580
2581     # See if we are still within the transit checkin forbidden range
2582     $self->push_events(OpenILS::Event->new('TRANSIT_CHECKIN_INTERVAL_BLOCK')) 
2583         if $horizon > DateTime->now;
2584 }
2585
2586 # Retarget local holds at checkin
2587 sub checkin_retarget {
2588     my $self = shift;
2589     return unless $self->retarget_mode and $self->retarget_mode =~ m/retarget/; # Retargeting?
2590     return unless $self->is_checkin; # Renewals need not be checked
2591     return if $self->capture eq 'nocapture'; # Not capturing holds anyway? Move on.
2592     return if $self->is_precat; # No holds for precats
2593     return unless $self->circ_lib == $self->copy->circ_lib; # Item isn't "home"? Don't check.
2594     return unless $U->is_true($self->copy->holdable); # Not holdable, shouldn't capture holds.
2595     my $status = $U->copy_status($self->copy->status);
2596     return unless $U->is_true($status->holdable); # Current status not holdable means no hold will ever target the item
2597     # Specifically target items that are likely new (by status ID)
2598     return unless $status->id == OILS_COPY_STATUS_IN_PROCESS || $self->retarget_mode =~ m/\.all/;
2599     my $location = $self->copy->location;
2600     if(!ref($location)) {
2601         $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
2602         $self->copy->location($location);
2603     }
2604     return unless $U->is_true($location->holdable); # Don't bother on non-holdable locations
2605
2606     # Fetch holds for the bib
2607     my ($result) = $holdcode->method_lookup('open-ils.circ.holds.retrieve_all_from_title')->run(
2608                     $self->editor->authtoken,
2609                     $self->title->id,
2610                     {
2611                         capture_time => undef, # No touching captured holds
2612                         frozen => 'f', # Don't bother with frozen holds
2613                         pickup_lib => $self->circ_lib # Only holds actually here
2614                     }); 
2615
2616     # Error? Skip the step.
2617     return if exists $result->{"ilsevent"};
2618
2619     # Assemble holds
2620     my $holds = [];
2621     foreach my $holdlist (keys %{$result}) {
2622         push @$holds, @{$result->{$holdlist}};
2623     }
2624
2625     return if scalar(@$holds) == 0; # No holds, no retargeting
2626
2627     # Check for parts on this copy
2628     my $parts = $self->editor->search_asset_copy_part_map({ target_copy => $self->copy->id });
2629     my %parts_hash = ();
2630     %parts_hash = map {$_->part, 1} @$parts if @$parts;
2631
2632     # Loop over holds in request-ish order
2633     # Stage 1: Get them into request-ish order
2634     # Also grab type and target for skipping low hanging ones
2635     $result = $self->editor->json_query({
2636         "select" => { "ahr" => ["id", "hold_type", "target"] },
2637         "from" => { "ahr" => { "au" => { "fkey" => "usr",  "join" => "pgt"} } },
2638         "where" => { "id" => $holds },
2639         "order_by" => [
2640             { "class" => "pgt", "field" => "hold_priority"},
2641             { "class" => "ahr", "field" => "cut_in_line", "direction" => "desc", "transform" => "coalesce", "params" => ['f']},
2642             { "class" => "ahr", "field" => "selection_depth", "direction" => "desc"},
2643             { "class" => "ahr", "field" => "request_time"}
2644         ]
2645     });
2646
2647     # Stage 2: Loop!
2648     if (ref $result eq "ARRAY" and scalar @$result) {
2649         foreach (@{$result}) {
2650             # Copy level, but not this copy?
2651             next if ($_->{hold_type} eq 'C' or $_->{hold_type} eq 'R' or $_->{hold_type} eq 'F'
2652                 and $_->{target} != $self->copy->id);
2653             # Volume level, but not this volume?
2654             next if ($_->{hold_type} eq 'V' and $_->{target} != $self->volume->id);
2655             if(@$parts) { # We have parts?
2656                 # Skip title holds
2657                 next if ($_->{hold_type} eq 'T');
2658                 # Skip part holds for parts not on this copy
2659                 next if ($_->{hold_type} eq 'P' and not $parts_hash{$_->{target}});
2660             } else {
2661                 # No parts, no part holds
2662                 next if ($_->{hold_type} eq 'P');
2663             }
2664             # So much for easy stuff, attempt a retarget!
2665             my $tresult = $U->simplereq(
2666                 'open-ils.hold-targeter',
2667                 'open-ils.hold-targeter.target', 
2668                 {hold => $_->{id}, find_copy => $self->copy->id}
2669             );
2670             if(ref $tresult eq "ARRAY" and scalar @$tresult) {
2671                 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
2672             }
2673         }
2674     }
2675 }
2676
2677 sub do_checkin {
2678     my $self = shift;
2679     $self->log_me("do_checkin()");
2680
2681     return $self->bail_on_events(
2682         OpenILS::Event->new('ASSET_COPY_NOT_FOUND')) 
2683         unless $self->copy;
2684
2685     $self->fix_broken_transit_status; # if applicable
2686     $self->check_transit_checkin_interval;
2687     $self->checkin_retarget;
2688
2689     # the renew code and mk_env should have already found our circulation object
2690     unless( $self->circ ) {
2691
2692         my $circs = $self->editor->search_action_circulation(
2693             { target_copy => $self->copy->id, checkin_time => undef });
2694
2695         $self->circ($$circs[0]);
2696
2697         # for now, just warn if there are multiple open circs on a copy
2698         $logger->warn("circulator: we have ".scalar(@$circs).
2699             " open circs for copy " .$self->copy->id."!!") if @$circs > 1;
2700     }
2701     $self->cancel_transit_if_circ_exists; # if applicable
2702
2703     my $stat = $U->copy_status($self->copy->status)->id;
2704
2705     # LOST (and to some extent, LONGOVERDUE) may optionally be handled
2706     # differently if they are already paid for.  We need to check for this
2707     # early since overdue generation is potentially affected.
2708     my $dont_change_lost_zero = 0;
2709     if ($stat == OILS_COPY_STATUS_LOST
2710         || $stat == OILS_COPY_STATUS_LOST_AND_PAID
2711         || $stat == OILS_COPY_STATUS_LONG_OVERDUE) {
2712
2713         # LOST fine settings are controlled by the copy's circ lib, not the the
2714         # circulation's
2715         my $copy_circ_lib = (ref $self->copy->circ_lib) ?
2716                 $self->copy->circ_lib->id : $self->copy->circ_lib;
2717         $dont_change_lost_zero = $U->ou_ancestor_setting_value(
2718             $copy_circ_lib, 'circ.checkin.lost_zero_balance.do_not_change',
2719             $self->editor) || 0;
2720
2721         # Don't assume there's always a circ based on copy status
2722         if ($dont_change_lost_zero && $self->circ) {
2723             my ($obt) = $U->fetch_mbts($self->circ->id, $self->editor);
2724             $dont_change_lost_zero = 0 if( $obt and $obt->balance_owed != 0 );
2725         }
2726
2727         $self->dont_change_lost_zero($dont_change_lost_zero);
2728     }
2729
2730     my $latest_inventory = Fieldmapper::asset::latest_inventory->new;
2731
2732     if ($self->do_inventory_update) {
2733         $latest_inventory->inventory_date('now');
2734         $latest_inventory->inventory_workstation($self->editor->requestor->wsid);
2735         $latest_inventory->copy($self->copy->id());
2736     } else {
2737         my $alci = $self->editor->search_asset_latest_inventory(
2738             {copy => $self->copy->id}
2739         );
2740         $latest_inventory = $alci->[0]
2741     }
2742     $self->latest_inventory($latest_inventory);
2743
2744     if( $self->checkin_check_holds_shelf() ) {
2745         $self->bail_on_events(OpenILS::Event->new('NO_CHANGE'));
2746         $self->hold($U->fetch_open_hold_by_copy($self->copy->id));
2747         if($self->fake_hold_dest) {
2748             $self->hold->pickup_lib($self->circ_lib);
2749         }
2750         $self->checkin_flesh_events;
2751         return;
2752     }
2753
2754     unless( $self->is_renewal ) {
2755         return $self->bail_on_events($self->editor->event)
2756             unless $self->editor->allowed('COPY_CHECKIN');
2757     }
2758
2759     $self->push_events($self->check_copy_alert());
2760     $self->push_events($self->check_checkin_copy_status());
2761
2762     # if the circ is marked as 'claims returned', add the event to the list
2763     $self->push_events(OpenILS::Event->new('CIRC_CLAIMS_RETURNED'))
2764         if ($self->circ and $self->circ->stop_fines 
2765                 and $self->circ->stop_fines eq OILS_STOP_FINES_CLAIMSRETURNED);
2766
2767     $self->check_circ_deposit();
2768
2769     # handle the overridable events 
2770     $self->override_events unless $self->is_renewal;
2771     return if $self->bail_out;
2772     
2773     if( $self->circ ) {
2774         $self->checkin_handle_circ_start;
2775         return if $self->bail_out;
2776
2777         if (!$dont_change_lost_zero) {
2778             # if this circ is LOST and we are configured to generate overdue
2779             # fines for lost items on checkin (to fill the gap between mark
2780             # lost time and when the fines would have naturally stopped), then
2781             # stop_fines is no longer valid and should be cleared.
2782             #
2783             # stop_fines will be set again during the handle_fines() stage.
2784             # XXX should this setting come from the copy circ lib (like other
2785             # LOST settings), instead of the circulation circ lib?
2786             if ($stat == OILS_COPY_STATUS_LOST) {
2787                 $self->circ->clear_stop_fines if
2788                     $U->ou_ancestor_setting_value(
2789                         $self->circ_lib,
2790                         OILS_SETTING_GENERATE_OVERDUE_ON_LOST_RETURN,
2791                         $self->editor
2792                     );
2793             }
2794
2795             # Set stop_fines when claimed never checked out
2796             $self->circ->stop_fines( OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT ) if( $self->claims_never_checked_out );
2797
2798             # handle fines for this circ, including overdue gen if needed
2799             $self->handle_fines;
2800         }
2801
2802         $self->checkin_handle_circ_finish;
2803         return if $self->bail_out;
2804         $self->checkin_changed(1);
2805
2806     } elsif( $self->transit ) {
2807         my $hold_transit = $self->process_received_transit;
2808         $self->checkin_changed(1);
2809
2810         if( $self->bail_out ) { 
2811             $self->checkin_flesh_events;
2812             return;
2813         }
2814         
2815         if( my $e = $self->check_checkin_copy_status() ) {
2816             # If the original copy status is special, alert the caller
2817             my $ev = $self->events;
2818             $self->events([$e]);
2819             $self->override_events;
2820             return if $self->bail_out;
2821             $self->events($ev);
2822         }
2823
2824         if( $hold_transit or 
2825                 $U->copy_status($self->copy->status)->id 
2826                     == OILS_COPY_STATUS_ON_HOLDS_SHELF ) {
2827
2828             my $hold;
2829             if( $hold_transit ) {
2830                $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
2831             } else {
2832                    ($hold) = $U->fetch_open_hold_by_copy($self->copy->id);
2833             }
2834
2835             $self->hold($hold);
2836
2837             if( $hold and ( $hold->cancel_time or $hold->fulfillment_time ) ) { # this transited hold was cancelled or filled mid-transit
2838
2839                 $logger->info("circulator: we received a transit on a cancelled or filled hold " . $hold->id);
2840                 $self->reshelve_copy(1);
2841                 $self->cancelled_hold_transit(1);
2842                 $self->notify_hold(0); # don't notify for cancelled holds
2843                 $self->fake_hold_dest(0);
2844                 return if $self->bail_out;
2845
2846             } elsif ($hold and $hold->hold_type eq 'R') {
2847
2848                 $self->copy->status(OILS_COPY_STATUS_CATALOGING);
2849                 $self->notify_hold(0); # No need to notify
2850                 $self->fake_hold_dest(0);
2851                 $self->noop(1); # Don't try and capture for other holds/transits now
2852                 $self->update_copy();
2853                 $hold->fulfillment_time('now');
2854                 $self->bail_on_events($self->editor->event)
2855                     unless $self->editor->update_action_hold_request($hold);
2856
2857             } else {
2858
2859                 # hold transited to correct location
2860                 if($self->fake_hold_dest) {
2861                     $hold->pickup_lib($self->circ_lib);
2862                 }
2863                 $self->checkin_flesh_events;
2864                 return;
2865             }
2866         } 
2867
2868     } elsif( $U->copy_status($self->copy->status)->id == OILS_COPY_STATUS_IN_TRANSIT ) {
2869
2870         $logger->warn("circulator: we have a copy ".$self->copy->barcode.
2871             " that is in-transit, but there is no transit.. repairing");
2872         $self->reshelve_copy(1);
2873         return if $self->bail_out;
2874     }
2875
2876     if( $self->is_renewal ) {
2877         $self->finish_fines_and_voiding;
2878         return if $self->bail_out;
2879         $self->push_events(OpenILS::Event->new('SUCCESS'));
2880         return;
2881     }
2882
2883    # ------------------------------------------------------------------------------
2884    # Circulations and transits are now closed where necessary.  Now go on to see if
2885    # this copy can fulfill a hold or needs to be routed to a different location
2886    # ------------------------------------------------------------------------------
2887
2888     my $needed_for_something = 0; # formerly "needed_for_hold"
2889
2890     if(!$self->noop) { # /not/ a no-op checkin, capture for hold or put item into transit
2891
2892         if (!$self->remote_hold) {
2893             if ($self->use_booking) {
2894                 my $potential_hold = $self->hold_capture_is_possible;
2895                 my $potential_reservation = $self->reservation_capture_is_possible;
2896
2897                 if ($potential_hold and $potential_reservation) {
2898                     $logger->info("circulator: item could fulfill either hold or reservation");
2899                     $self->push_events(new OpenILS::Event(
2900                         "HOLD_RESERVATION_CONFLICT",
2901                         "hold" => $potential_hold,
2902                         "reservation" => $potential_reservation
2903                     ));
2904                     return if $self->bail_out;
2905                 } elsif ($potential_hold) {
2906                     $needed_for_something =
2907                         $self->attempt_checkin_hold_capture;
2908                 } elsif ($potential_reservation) {
2909                     $needed_for_something =
2910                         $self->attempt_checkin_reservation_capture;
2911                 }
2912             } else {
2913                 $needed_for_something = $self->attempt_checkin_hold_capture;
2914             }
2915         }
2916         return if $self->bail_out;
2917     
2918         unless($needed_for_something) {
2919             my $circ_lib = (ref $self->copy->circ_lib) ? 
2920                     $self->copy->circ_lib->id : $self->copy->circ_lib;
2921     
2922             if( $self->remote_hold ) {
2923                 $circ_lib = $self->remote_hold->pickup_lib;
2924                 $logger->warn("circulator: Copy ".$self->copy->barcode.
2925                     " is on a remote hold's shelf, sending to $circ_lib");
2926             }
2927     
2928             $logger->debug("circulator: circlib=$circ_lib, workstation=".$self->circ_lib);
2929
2930             my $suppress_transit = 0;
2931
2932             if( $circ_lib != $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) {
2933                 my $suppress_transit_source = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_non_hold');
2934                 if($suppress_transit_source && $suppress_transit_source->{value}) {
2935                     my $suppress_transit_dest = $U->ou_ancestor_setting($circ_lib, 'circ.transit.suppress_non_hold');
2936                     if($suppress_transit_dest && $suppress_transit_source->{value} eq $suppress_transit_dest->{value}) {
2937                         $logger->info("circulator: copy is within transit suppress group: ".$self->copy->barcode." ".$suppress_transit_source->{value});
2938                         $suppress_transit = 1;
2939                     }
2940                 }
2941             }
2942  
2943             if( $suppress_transit or ( $circ_lib == $self->circ_lib and not ($self->hold_as_transit and $self->remote_hold) ) ) {
2944                 # copy is where it needs to be, either for hold or reshelving
2945     
2946                 $self->checkin_handle_precat();
2947                 return if $self->bail_out;
2948     
2949             } else {
2950                 # copy needs to transit "home", or stick here if it's a floating copy
2951                 my $can_float = 0;
2952                 if ($self->copy->floating && ($self->manual_float || !$U->is_true($self->copy->floating->manual)) && !$self->remote_hold) { # copy is potentially floating?
2953                     my $res = $self->editor->json_query(
2954                         {   from => [
2955                                 'evergreen.can_float',
2956                                 $self->copy->floating->id,
2957                                 $self->copy->circ_lib,
2958                                 $self->circ_lib
2959                             ]
2960                         }
2961                     );
2962                     $can_float = $U->is_true($res->[0]->{'evergreen.can_float'}) if $res; 
2963                 }
2964                 if ($can_float) { # Yep, floating, stick here
2965                     $self->checkin_changed(1);
2966                     $self->copy->circ_lib( $self->circ_lib );
2967                     $self->update_copy;
2968                 } else {
2969                     my $bc = $self->copy->barcode;
2970                     $logger->info("circulator: copy $bc at the wrong location, sending to $circ_lib");
2971                     $self->checkin_build_copy_transit($circ_lib);
2972                     return if $self->bail_out;
2973                     $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $circ_lib));
2974                 }
2975             }
2976         }
2977     } else { # no-op checkin
2978         if ($self->copy->floating) { # XXX floating items still stick where they are even with no-op checkin?
2979             my $res = $self->editor->json_query(
2980                 {
2981                     from => [
2982                         'evergreen.can_float',
2983                         $self->copy->floating->id,
2984                         $self->copy->circ_lib,
2985                         $self->circ_lib
2986                     ]
2987                 }
2988             );
2989             if ($res && @$res && $U->is_true($res->[0]->{'evergreen.can_float'})) {
2990                 $self->checkin_changed(1);
2991                 $self->copy->circ_lib( $self->circ_lib );
2992                 $self->update_copy;
2993             }
2994         }
2995     }
2996
2997     if($self->claims_never_checked_out and 
2998             $U->ou_ancestor_setting_value($self->circ->circ_lib, 'circ.claim_never_checked_out.mark_missing')) {
2999
3000         # the item was not supposed to be checked out to the user and should now be marked as missing
3001         my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_MISSING;
3002         $self->copy->status($next_status);
3003         $self->update_copy;
3004
3005     } else {
3006         $self->reshelve_copy unless $needed_for_something;
3007     }
3008
3009     return if $self->bail_out;
3010
3011     unless($self->checkin_changed) {
3012
3013         $self->push_events(OpenILS::Event->new('NO_CHANGE'));
3014         my $stat = $U->copy_status($self->copy->status)->id;
3015
3016         $self->hold($U->fetch_open_hold_by_copy($self->copy->id))
3017          if( $stat == OILS_COPY_STATUS_ON_HOLDS_SHELF );
3018         $self->bail_out(1); # no need to commit anything
3019
3020     } else {
3021
3022         $self->push_events(OpenILS::Event->new('SUCCESS')) 
3023             unless @{$self->events};
3024     }
3025
3026     $self->finish_fines_and_voiding;
3027
3028     OpenILS::Utils::Penalty->calculate_penalties(
3029         $self->editor, $self->patron->id, $self->circ_lib) if $self->patron;
3030
3031     $self->checkin_flesh_events;
3032     return;
3033 }
3034
3035 sub finish_fines_and_voiding {
3036     my $self = shift;
3037     return unless $self->circ;
3038
3039     return unless $self->backdate or $self->void_overdues;
3040
3041     # void overdues after fine generation to prevent concurrent DB access to overdue billings
3042     my $note = 'System: Amnesty Checkin' if $self->void_overdues;
3043
3044     my $evt = $CC->void_or_zero_overdues(
3045         $self->editor, $self->circ, {backdate => $self->void_overdues ? undef : $self->backdate, note => $note});
3046
3047     return $self->bail_on_events($evt) if $evt;
3048
3049     # Make sure the circ is open or closed as necessary.
3050     $evt = $U->check_open_xact($self->editor, $self->circ->id);
3051     return $self->bail_on_events($evt) if $evt;
3052
3053     return undef;
3054 }
3055
3056
3057 # if a deposit was payed for this item, push the event
3058 sub check_circ_deposit {
3059     my $self = shift;
3060     return unless $self->circ;
3061     my $deposit = $self->editor->search_money_billing(
3062         {   btype => 5, 
3063             xact => $self->circ->id, 
3064             voided => 'f'
3065         }, {idlist => 1})->[0];
3066
3067     $self->push_events(OpenILS::Event->new(
3068         'ITEM_DEPOSIT_PAID', payload => $deposit)) if $deposit;
3069 }
3070
3071 sub reshelve_copy {
3072    my $self    = shift;
3073    my $force   = $self->force || shift;
3074    my $copy    = $self->copy;
3075
3076    my $stat = $U->copy_status($copy->status)->id;
3077
3078    my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3079
3080    if($force || (
3081       $stat != OILS_COPY_STATUS_ON_HOLDS_SHELF and
3082       $stat != OILS_COPY_STATUS_CATALOGING and
3083       $stat != OILS_COPY_STATUS_IN_TRANSIT and
3084       $stat != $next_status  )) {
3085
3086         $copy->status( $next_status );
3087             $self->update_copy;
3088             $self->checkin_changed(1);
3089     }
3090 }
3091
3092
3093 # Returns true if the item is at the current location
3094 # because it was transited there for a hold and the 
3095 # hold has not been fulfilled
3096 sub checkin_check_holds_shelf {
3097     my $self = shift;
3098     return 0 unless $self->copy;
3099
3100     return 0 unless 
3101         $U->copy_status($self->copy->status)->id ==
3102             OILS_COPY_STATUS_ON_HOLDS_SHELF;
3103
3104     # Attempt to clear shelf expired holds for this copy
3105     $holdcode->method_lookup('open-ils.circ.hold.clear_shelf.process')->run($self->editor->authtoken, $self->circ_lib, $self->copy->id)
3106         if($self->clear_expired);
3107
3108     # find the hold that put us on the holds shelf
3109     my $holds = $self->editor->search_action_hold_request(
3110         { 
3111             current_copy => $self->copy->id,
3112             capture_time => { '!=' => undef },
3113             fulfillment_time => undef,
3114             cancel_time => undef,
3115         }
3116     );
3117
3118     unless(@$holds) {
3119         $logger->warn("circulator: copy is on-holds-shelf, but there is no hold - reshelving");
3120         $self->reshelve_copy(1);
3121         return 0;
3122     }
3123
3124     my $hold = $$holds[0];
3125
3126     $logger->info("circulator: we found a captured, un-fulfilled hold [".
3127         $hold->id. "] for copy ".$self->copy->barcode);
3128
3129     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3130         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3131         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3132             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3133             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3134                 $logger->info("circulator: hold is within hold transit suppress group .. we're done: ".$self->copy->barcode." ".$suppress_transit_circ->{value});
3135                 $self->fake_hold_dest(1);
3136                 return 1;
3137             }
3138         }
3139     }
3140
3141     if( $hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit ) {
3142         $logger->info("circulator: hold is for here .. we're done: ".$self->copy->barcode);
3143         return 1;
3144     }
3145
3146     $logger->info("circulator: hold is not for here..");
3147     $self->remote_hold($hold);
3148     return 0;
3149 }
3150
3151
3152 sub checkin_handle_precat {
3153     my $self    = shift;
3154    my $copy    = $self->copy;
3155
3156    if( $self->is_precat and ($copy->status != OILS_COPY_STATUS_CATALOGING) ) {
3157         $copy->status(OILS_COPY_STATUS_CATALOGING);
3158         $self->update_copy();
3159         $self->checkin_changed(1);
3160         $self->push_events(OpenILS::Event->new('ITEM_NOT_CATALOGED'));
3161    }
3162 }
3163
3164
3165 sub checkin_build_copy_transit {
3166     my $self            = shift;
3167     my $dest            = shift;
3168     my $copy       = $self->copy;
3169     my $transit    = Fieldmapper::action::transit_copy->new;
3170
3171     # if we are transiting an item to the shelf shelf, it's a hold transit
3172     if (my $hold = $self->remote_hold) {
3173         $transit = Fieldmapper::action::hold_transit_copy->new;
3174         $transit->hold($hold->id);
3175
3176         # the item is going into transit, remove any shelf-iness
3177         if ($hold->current_shelf_lib or $hold->shelf_time) {
3178             $hold->clear_current_shelf_lib;
3179             $hold->clear_shelf_time;
3180             return $self->bail_on_events($self->editor->event)
3181                 unless $self->editor->update_action_hold_request($hold);
3182         }
3183     }
3184
3185     #$dest  ||= (ref($copy->circ_lib)) ? $copy->circ_lib->id : $copy->circ_lib;
3186     $logger->info("circulator: transiting copy to $dest");
3187
3188     $transit->source($self->circ_lib);
3189     $transit->dest($dest);
3190     $transit->target_copy($copy->id);
3191     $transit->source_send_time('now');
3192     $transit->copy_status( $U->copy_status($copy->status)->id );
3193
3194     $logger->debug("circulator: setting copy status on transit: ".$transit->copy_status);
3195
3196     if ($self->remote_hold) {
3197         return $self->bail_on_events($self->editor->event)
3198             unless $self->editor->create_action_hold_transit_copy($transit);
3199     } else {
3200         return $self->bail_on_events($self->editor->event)
3201             unless $self->editor->create_action_transit_copy($transit);
3202     }
3203
3204     # ensure the transit is returned to the caller
3205     $self->transit($transit);
3206
3207     $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3208     $self->update_copy;
3209     $self->checkin_changed(1);
3210 }
3211
3212
3213 sub hold_capture_is_possible {
3214     my $self = shift;
3215     my $copy = $self->copy;
3216
3217     # we've been explicitly told not to capture any holds
3218     return 0 if $self->capture eq 'nocapture';
3219
3220     # See if this copy can fulfill any holds
3221     my $hold = $holdcode->find_nearest_permitted_hold(
3222         $self->editor, $copy, $self->editor->requestor, 1 # check_only
3223     );
3224     return undef if ref $hold eq "HASH" and
3225         $hold->{"textcode"} eq "ACTION_HOLD_REQUEST_NOT_FOUND";
3226     return $hold;
3227 }
3228
3229 sub reservation_capture_is_possible {
3230     my $self = shift;
3231     my $copy = $self->copy;
3232
3233     # we've been explicitly told not to capture any holds
3234     return 0 if $self->capture eq 'nocapture';
3235
3236     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3237     my $resv = $booking_ses->request(
3238         "open-ils.booking.reservations.could_capture",
3239         $self->editor->authtoken, $copy->barcode
3240     )->gather(1);
3241     $booking_ses->disconnect;
3242     if (ref($resv) eq "HASH" and exists $resv->{"textcode"}) {
3243         $self->push_events($resv);
3244     } else {
3245         return $resv;
3246     }
3247 }
3248
3249 # returns true if the item was used (or may potentially be used 
3250 # in subsequent calls) to capture a hold.
3251 sub attempt_checkin_hold_capture {
3252     my $self = shift;
3253     my $copy = $self->copy;
3254
3255     # we've been explicitly told not to capture any holds
3256     return 0 if $self->capture eq 'nocapture';
3257
3258     # See if this copy can fulfill any holds
3259     my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold( 
3260         $self->editor, $copy, $self->editor->requestor );
3261
3262     if(!$hold) {
3263         $logger->debug("circulator: no potential permitted".
3264             "holds found for copy ".$copy->barcode);
3265         return 0;
3266     }
3267
3268     if($self->capture ne 'capture') {
3269         # see if this item is in a hold-capture-delay location
3270         my $location = $self->copy->location;
3271         if(!ref($location)) {
3272             $location = $self->editor->retrieve_asset_copy_location($self->copy->location);
3273             $self->copy->location($location);
3274         }
3275         if($U->is_true($location->hold_verify)) {
3276             $self->bail_on_events(
3277                 OpenILS::Event->new('HOLD_CAPTURE_DELAYED', copy_location => $location));
3278             return 1;
3279         }
3280     }
3281
3282     $self->retarget($retarget);
3283
3284     my $suppress_transit = 0;
3285     if( $hold->pickup_lib != $self->circ_lib and not $self->hold_as_transit ) {
3286         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, 'circ.transit.suppress_hold');
3287         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3288             my $suppress_transit_pickup = $U->ou_ancestor_setting($hold->pickup_lib, 'circ.transit.suppress_hold');
3289             if($suppress_transit_pickup && $suppress_transit_circ->{value} eq $suppress_transit_pickup->{value}) {
3290                 $suppress_transit = 1;
3291                 $hold->pickup_lib($self->circ_lib);
3292             }
3293         }
3294     }
3295
3296     $logger->info("circulator: found permitted hold ".$hold->id." for copy, capturing...");
3297
3298     $hold->current_copy($copy->id);
3299     $hold->capture_time('now');
3300     $self->put_hold_on_shelf($hold) 
3301         if ($suppress_transit || ($hold->pickup_lib == $self->circ_lib and not $self->hold_as_transit) );
3302
3303     # prevent DB errors caused by fetching 
3304     # holds from storage, and updating through cstore
3305     $hold->clear_fulfillment_time;
3306     $hold->clear_fulfillment_staff;
3307     $hold->clear_fulfillment_lib;
3308     $hold->clear_expire_time; 
3309     $hold->clear_cancel_time;
3310     $hold->clear_prev_check_time unless $hold->prev_check_time;
3311
3312     $self->bail_on_events($self->editor->event)
3313         unless $self->editor->update_action_hold_request($hold);
3314     $self->hold($hold);
3315     $self->checkin_changed(1);
3316
3317     return 0 if $self->bail_out;
3318
3319     if( $suppress_transit or ( $hold->pickup_lib == $self->circ_lib && not $self->hold_as_transit ) ) {
3320
3321         if ($hold->hold_type eq 'R') {
3322             $copy->status(OILS_COPY_STATUS_CATALOGING);
3323             $hold->fulfillment_time('now');
3324             $self->noop(1); # Block other transit/hold checks
3325             $self->bail_on_events($self->editor->event)
3326                 unless $self->editor->update_action_hold_request($hold);
3327         } else {
3328             # This hold was captured in the correct location
3329             $copy->status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3330             $self->push_events(OpenILS::Event->new('SUCCESS'));
3331
3332             #$self->do_hold_notify($hold->id);
3333             $self->notify_hold($hold->id);
3334         }
3335
3336     } else {
3337     
3338         # Hold needs to be picked up elsewhere.  Build a hold
3339         # transit and route the item.
3340         $self->checkin_build_hold_transit();
3341         $copy->status(OILS_COPY_STATUS_IN_TRANSIT);
3342         return 0 if $self->bail_out;
3343         $self->push_events(OpenILS::Event->new('ROUTE_ITEM', org => $hold->pickup_lib));
3344     }
3345
3346     # make sure we save the copy status
3347     $self->update_copy;
3348     return 0 if $copy->status == OILS_COPY_STATUS_CATALOGING;
3349     return 1;
3350 }
3351
3352 sub attempt_checkin_reservation_capture {
3353     my $self = shift;
3354     my $copy = $self->copy;
3355
3356     # we've been explicitly told not to capture any holds
3357     return 0 if $self->capture eq 'nocapture';
3358
3359     my $booking_ses = OpenSRF::AppSession->connect("open-ils.booking");
3360     my $evt = $booking_ses->request(
3361         "open-ils.booking.resources.capture_for_reservation",
3362         $self->editor->authtoken,
3363         $copy->barcode,
3364         1 # don't update copy - we probably have it locked
3365     )->gather(1);
3366     $booking_ses->disconnect;
3367
3368     if (ref($evt) ne "HASH" or not exists $evt->{"textcode"}) {
3369         $logger->warn(
3370             "open-ils.booking.resources.capture_for_reservation " .
3371             "didn't return an event!"
3372         );
3373     } else {
3374         if (
3375             $evt->{"textcode"} eq "RESERVATION_NOT_FOUND" and
3376             $evt->{"payload"}->{"fail_cause"} eq "not-transferable"
3377         ) {
3378             # not-transferable is an error event we'll pass on the user
3379             $logger->warn("reservation capture attempted against non-transferable item");
3380             $self->push_events($evt);
3381             return 0;
3382         } elsif ($evt->{"textcode"} eq "SUCCESS") {
3383             # Re-retrieve copy as reservation capture may have changed
3384             # its status and whatnot.
3385             $logger->info(
3386                 "circulator: booking capture win on copy " . $self->copy->id
3387             );
3388             if (my $new_copy_status = $evt->{"payload"}->{"new_copy_status"}) {
3389                 $logger->info(
3390                     "circulator: changing copy " . $self->copy->id .
3391                     "'s status from " . $self->copy->status . " to " .
3392                     $new_copy_status
3393                 );
3394                 $self->copy->status($new_copy_status);
3395                 $self->update_copy;
3396             }
3397             $self->reservation($evt->{"payload"}->{"reservation"});
3398
3399             if (exists $evt->{"payload"}->{"transit"}) {
3400                 $self->push_events(
3401                     new OpenILS::Event(
3402                         "ROUTE_ITEM",
3403                         "org" => $evt->{"payload"}->{"transit"}->dest
3404                     )
3405                 );
3406             }
3407             $self->checkin_changed(1);
3408             return 1;
3409         }
3410     }
3411     # other results are treated as "nothing to capture"
3412     return 0;
3413 }
3414
3415 sub do_hold_notify {
3416     my( $self, $holdid ) = @_;
3417
3418     my $e = new_editor(xact => 1);
3419     my $hold = $e->retrieve_action_hold_request($holdid) or return $e->die_event;
3420     $e->rollback;
3421     my $ses = OpenSRF::AppSession->create('open-ils.trigger');
3422     $ses->request('open-ils.trigger.event.autocreate', 'hold.available', $hold, $hold->pickup_lib);
3423
3424     $logger->info("circulator: running delayed hold notify process");
3425
3426 #   my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3427 #       hold_id => $holdid, editor => new_editor(requestor=>$self->editor->requestor));
3428
3429     my $notifier = OpenILS::Application::Circ::HoldNotify->new(
3430         hold_id => $holdid, requestor => $self->editor->requestor);
3431
3432     $logger->debug("circulator: built hold notifier");
3433
3434     if(!$notifier->event) {
3435
3436         $logger->info("circulator: attempt at sending hold notification for hold $holdid");
3437
3438         my $stat = $notifier->send_email_notify;
3439         if( $stat == '1' ) {
3440             $logger->info("circulator: hold notify succeeded for hold $holdid");
3441             return;
3442         } 
3443
3444         $logger->debug("circulator:  * hold notify cancelled or failed for hold $holdid");
3445
3446     } else {
3447         $logger->info("circulator: Not sending hold notification since the patron has no email address");
3448     }
3449 }
3450
3451 sub retarget_holds {
3452     my $self = shift;
3453     $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
3454     my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
3455     $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
3456     # no reason to wait for the return value
3457     return;
3458 }
3459
3460 sub checkin_build_hold_transit {
3461     my $self = shift;
3462
3463    my $copy = $self->copy;
3464    my $hold = $self->hold;
3465    my $trans = Fieldmapper::action::hold_transit_copy->new;
3466
3467     $logger->debug("circulator: building hold transit for ".$copy->barcode);
3468
3469    $trans->hold($hold->id);
3470    $trans->source($self->circ_lib);
3471    $trans->dest($hold->pickup_lib);
3472    $trans->source_send_time("now");
3473    $trans->target_copy($copy->id);
3474
3475     # when the copy gets to its destination, it will recover
3476     # this status - put it onto the holds shelf
3477    $trans->copy_status(OILS_COPY_STATUS_ON_HOLDS_SHELF);
3478
3479     return $self->bail_on_events($self->editor->event)
3480         unless $self->editor->create_action_hold_transit_copy($trans);
3481 }
3482
3483
3484
3485 sub process_received_transit {
3486     my $self = shift;
3487     my $copy = $self->copy;
3488     my $copyid = $self->copy->id;
3489
3490     my $status_name = $U->copy_status($copy->status)->name;
3491     $logger->debug("circulator: attempting transit receive on ".
3492         "copy $copyid. Copy status is $status_name");
3493
3494     my $transit = $self->transit;
3495
3496     # Check if we are in a transit suppress range
3497     my $suppress_transit = 0;
3498     if ( $transit->dest != $self->circ_lib and not ( $self->hold_as_transit and $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ) ) {
3499         my $suppress_setting = ($transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF ?  'circ.transit.suppress_hold' : 'circ.transit.suppress_non_hold');
3500         my $suppress_transit_circ = $U->ou_ancestor_setting($self->circ_lib, $suppress_setting);
3501         if($suppress_transit_circ && $suppress_transit_circ->{value}) {
3502             my $suppress_transit_dest = $U->ou_ancestor_setting($transit->dest, $suppress_setting);
3503             if($suppress_transit_dest && $suppress_transit_dest->{value} eq $suppress_transit_circ->{value}) {
3504                 $suppress_transit = 1;
3505                 $self->fake_hold_dest(1) if $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF;
3506             }
3507         }
3508     }
3509     if( not $suppress_transit and ( $transit->dest != $self->circ_lib or ($self->hold_as_transit && $transit->copy_status == OILS_COPY_STATUS_ON_HOLDS_SHELF) ) ) {
3510         # - this item is in-transit to a different location
3511         # - Or we are capturing holds as transits, so why create a new transit?
3512
3513         my $tid = $transit->id; 
3514         my $loc = $self->circ_lib;
3515         my $dest = $transit->dest;
3516
3517         $logger->info("circulator: Fowarding transit on copy which is destined ".
3518             "for a different location. transit=$tid, copy=$copyid, current ".
3519             "location=$loc, destination location=$dest");
3520
3521         my $evt = OpenILS::Event->new('ROUTE_ITEM', org => $dest, payload => {});
3522
3523         # grab the associated hold object if available
3524         my $ht = $self->editor->retrieve_action_hold_transit_copy($tid);
3525         $self->hold($self->editor->retrieve_action_hold_request($ht->hold)) if $ht;
3526
3527         return $self->bail_on_events($evt);
3528     }
3529
3530     # The transit is received, set the receive time
3531     $transit->dest_recv_time('now');
3532     $self->bail_on_events($self->editor->event)
3533         unless $self->editor->update_action_transit_copy($transit);
3534
3535     my $hold_transit = $self->editor->retrieve_action_hold_transit_copy($transit->id);
3536
3537     $logger->info("circulator: Recovering original copy status in transit: ".$transit->copy_status);
3538     $copy->status( $transit->copy_status );
3539     $self->update_copy();
3540     return if $self->bail_out;
3541
3542     my $ishold = 0;
3543     if($hold_transit) { 
3544         my $hold = $self->editor->retrieve_action_hold_request($hold_transit->hold);
3545
3546         if ($hold) {
3547             # hold has arrived at destination, set shelf time
3548             $self->put_hold_on_shelf($hold);
3549             $self->bail_on_events($self->editor->event)
3550                 unless $self->editor->update_action_hold_request($hold);
3551             return if $self->bail_out;
3552
3553             $self->notify_hold($hold_transit->hold);
3554             $ishold = 1;
3555         } else {
3556             $hold_transit = undef;
3557             $self->cancelled_hold_transit(1);
3558             $self->reshelve_copy(1);
3559             $self->fake_hold_dest(0);
3560         }
3561     }
3562
3563     $self->push_events( 
3564         OpenILS::Event->new(
3565         'SUCCESS', 
3566         ishold => $ishold,
3567       payload => { transit => $transit, holdtransit => $hold_transit } ));
3568
3569     return $hold_transit;
3570 }
3571
3572
3573 # ------------------------------------------------------------------
3574 # Sets the shelf_time and shelf_expire_time for a newly shelved hold
3575 # ------------------------------------------------------------------
3576 sub put_hold_on_shelf {
3577     my($self, $hold) = @_;
3578     $hold->shelf_time('now');
3579     $hold->current_shelf_lib($self->circ_lib);
3580     $holdcode->set_hold_shelf_expire_time($hold, $self->editor);
3581     return undef;
3582 }
3583
3584 sub handle_fines {
3585    my $self = shift;
3586    my $reservation = shift;
3587    my $dt_parser = DateTime::Format::ISO8601->new;
3588
3589    my $obj = $reservation ? $self->reservation : $self->circ;
3590
3591     my $lost_bill_opts = $self->lost_bill_options;
3592     my $circ_lib = $lost_bill_opts->{circ_lib} if $lost_bill_opts;
3593     # first, restore any voided overdues for lost, if needed
3594     if ($self->needs_lost_bill_handling and !$self->void_overdues) {
3595         my $restore_od = $U->ou_ancestor_setting_value(
3596             $circ_lib, $lost_bill_opts->{ous_restore_overdue},
3597             $self->editor) || 0;
3598         $self->checkin_handle_lost_or_lo_now_found_restore_od($circ_lib)
3599             if $restore_od;
3600     }
3601
3602     # next, handle normal overdue generation and apply stop_fines
3603     # XXX reservations don't have stop_fines
3604     # TODO revisit booking_reservation re: stop_fines support
3605     if ($reservation or !$obj->stop_fines) {
3606         my $skip_for_grace;
3607
3608         # This is a crude check for whether we are in a grace period. The code
3609         # in generate_fines() does a more thorough job, so this exists solely
3610         # as a small optimization, and might be better off removed.
3611
3612         # If we have a grace period
3613         if($obj->can('grace_period')) {
3614             # Parse out the due date
3615             my $due_date = $dt_parser->parse_datetime( clean_ISO8601($obj->due_date) );
3616             # Add the grace period to the due date
3617             $due_date->add(seconds => OpenILS::Utils::DateTime->interval_to_seconds($obj->grace_period));
3618             # Don't generate fines on circs still in grace period
3619             $skip_for_grace = $due_date > DateTime->now;
3620         }
3621         $CC->generate_fines({circs => [$obj], editor => $self->editor})
3622             unless $skip_for_grace;
3623
3624         if (!$reservation and !$obj->stop_fines) {
3625             $obj->stop_fines(OILS_STOP_FINES_CHECKIN);
3626             $obj->stop_fines(OILS_STOP_FINES_RENEW) if $self->is_renewal;
3627             $obj->stop_fines(OILS_STOP_FINES_CLAIMS_NEVERCHECKEDOUT) if $self->claims_never_checked_out;
3628             $obj->stop_fines_time('now');
3629             $obj->stop_fines_time($self->backdate) if $self->backdate;
3630             $self->editor->update_action_circulation($obj);
3631         }
3632     }
3633
3634     # finally, handle voiding of lost item and processing fees
3635     if ($self->needs_lost_bill_handling) {
3636         my $void_cost = $U->ou_ancestor_setting_value(
3637             $circ_lib, $lost_bill_opts->{ous_void_item_cost},
3638             $self->editor) || 0;
3639         my $void_proc_fee = $U->ou_ancestor_setting_value(
3640             $circ_lib, $lost_bill_opts->{ous_void_proc_fee},
3641             $self->editor) || 0;
3642         $self->checkin_handle_lost_or_lo_now_found(
3643             $lost_bill_opts->{void_cost_btype},
3644             $lost_bill_opts->{is_longoverdue}) if $void_cost;
3645         $self->checkin_handle_lost_or_lo_now_found(
3646             $lost_bill_opts->{void_fee_btype},
3647             $lost_bill_opts->{is_longoverdue}) if $void_proc_fee;
3648     }
3649
3650    return undef;
3651 }
3652
3653 sub checkin_handle_circ_start {
3654    my $self = shift;
3655    my $circ = $self->circ;
3656    my $copy = $self->copy;
3657    my $evt;
3658    my $obt;
3659
3660    $self->backdate($circ->xact_start) if $self->claims_never_checked_out;
3661
3662    # backdate the circ if necessary
3663    if($self->backdate) {
3664         my $evt = $self->checkin_handle_backdate;
3665         return $self->bail_on_events($evt) if $evt;
3666    }
3667
3668     # Set the checkin vars since we have the item
3669     $circ->checkin_time( ($self->backdate) ? $self->backdate : 'now' );
3670
3671     # capture the true scan time for back-dated checkins
3672     $circ->checkin_scan_time('now');
3673
3674     $circ->checkin_staff($self->editor->requestor->id);
3675     $circ->checkin_lib($self->circ_lib);
3676     $circ->checkin_workstation($self->editor->requestor->wsid);
3677
3678     my $circ_lib = (ref $self->copy->circ_lib) ?  
3679         $self->copy->circ_lib->id : $self->copy->circ_lib;
3680     my $stat = $U->copy_status($self->copy->status)->id;
3681
3682     if ($stat == OILS_COPY_STATUS_LOST || $stat == OILS_COPY_STATUS_LOST_AND_PAID) {
3683         # we will now handle lost fines, but the copy will retain its 'lost'
3684         # status if it needs to transit home unless lost_immediately_available
3685         # is true
3686         #
3687         # if we decide to also delay fine handling until the item arrives home,
3688         # we will need to call lost fine handling code both when checking items
3689         # in and also when receiving transits
3690         $self->checkin_handle_lost($circ_lib);
3691     } elsif ($stat == OILS_COPY_STATUS_LONG_OVERDUE) {
3692         # same process as above.
3693         $self->checkin_handle_long_overdue($circ_lib);
3694     } elsif ($circ_lib != $self->circ_lib and $stat == OILS_COPY_STATUS_MISSING) {
3695         $logger->info("circulator: not updating copy status on checkin because copy is missing");
3696     } else {
3697         my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3698         $self->copy->status($U->copy_status($next_status));
3699         $self->update_copy;
3700     }
3701
3702     return undef;
3703 }
3704
3705 sub checkin_handle_circ_finish {
3706     my $self = shift;
3707     my $e = $self->editor;
3708     my $circ = $self->circ;
3709
3710     # Do one last check before the final circulation update to see 
3711     # if the xact_finish value should be set or not.
3712     #
3713     # The underlying money.billable_xact may have been updated to
3714     # reflect a change in xact_finish during checkin bills handling, 
3715     # however we can't simply refresh the circulation from the DB,
3716     # because other changes may be pending.  Instead, reproduce the
3717     # xact_finish check here.  It won't hurt to do it again.
3718
3719     my $sum = $e->retrieve_money_billable_transaction_summary($circ->id);
3720     if ($sum) { # is this test still needed?
3721
3722         my $balance = $sum->balance_owed;
3723
3724         if ($balance == 0) {
3725             $circ->xact_finish('now');
3726         } else {
3727             $circ->clear_xact_finish;
3728         }
3729
3730         $logger->info("circulator: $balance is owed on this circulation");
3731     }
3732
3733     return $self->bail_on_events($e->event)
3734         unless $e->update_action_circulation($circ);
3735
3736     return undef;
3737 }
3738
3739 # ------------------------------------------------------------------
3740 # See if we need to void billings, etc. for lost checkin
3741 # ------------------------------------------------------------------
3742 sub checkin_handle_lost {
3743     my $self = shift;
3744     my $circ_lib = shift;
3745
3746     my $max_return = $U->ou_ancestor_setting_value($circ_lib, 
3747         OILS_SETTING_MAX_ACCEPT_RETURN_OF_LOST, $self->editor) || 0;
3748
3749     $self->lost_bill_options({
3750         circ_lib => $circ_lib,
3751         ous_void_item_cost => OILS_SETTING_VOID_LOST_ON_CHECKIN,
3752         ous_void_proc_fee => OILS_SETTING_VOID_LOST_PROCESS_FEE_ON_CHECKIN,
3753         ous_restore_overdue => OILS_SETTING_RESTORE_OVERDUE_ON_LOST_RETURN,
3754         void_cost_btype => 3, 
3755         void_fee_btype => 4 
3756     });
3757
3758     return $self->checkin_handle_lost_or_longoverdue(
3759         circ_lib => $circ_lib,
3760         max_return => $max_return,
3761         ous_immediately_available => OILS_SETTING_LOST_IMMEDIATELY_AVAILABLE,
3762         ous_use_last_activity => undef # not supported for LOST checkin
3763     );
3764 }
3765
3766 # ------------------------------------------------------------------
3767 # See if we need to void billings, etc. for long-overdue checkin
3768 # note: not using constants below since they serve little purpose 
3769 # for single-use strings that are descriptive in their own right 
3770 # and mostly just complicate debugging.
3771 # ------------------------------------------------------------------
3772 sub checkin_handle_long_overdue {
3773     my $self = shift;
3774     my $circ_lib = shift;
3775
3776     $logger->info("circulator: processing long-overdue checkin...");
3777
3778     my $max_return = $U->ou_ancestor_setting_value($circ_lib, 
3779         'circ.max_accept_return_of_longoverdue', $self->editor) || 0;
3780
3781     $self->lost_bill_options({
3782         circ_lib => $circ_lib,
3783         ous_void_item_cost => 'circ.void_longoverdue_on_checkin',
3784         ous_void_proc_fee => 'circ.void_longoverdue_proc_fee_on_checkin',
3785         is_longoverdue => 1,
3786         ous_restore_overdue => 'circ.restore_overdue_on_longoverdue_return',
3787         void_cost_btype => 10,
3788         void_fee_btype => 11
3789     });
3790
3791     return $self->checkin_handle_lost_or_longoverdue(
3792         circ_lib => $circ_lib,
3793         max_return => $max_return,
3794         ous_immediately_available => 'circ.longoverdue_immediately_available',
3795         ous_use_last_activity => 
3796             'circ.longoverdue.use_last_activity_date_on_return'
3797     )
3798 }
3799
3800 # last billing activity is last payment time, last billing time, or the 
3801 # circ due date.  If the relevant "use last activity" org unit setting is 
3802 # false/unset, then last billing activity is always the due date.
3803 sub get_circ_last_billing_activity {
3804     my $self = shift;
3805     my $circ_lib = shift;
3806     my $setting = shift;
3807     my $date = $self->circ->due_date;
3808
3809     return $date unless $setting and 
3810         $U->ou_ancestor_setting_value($circ_lib, $setting, $self->editor);
3811
3812     my $xact = $self->editor->retrieve_money_billable_transaction([
3813         $self->circ->id,
3814         {flesh => 1, flesh_fields => {mbt => ['summary']}}
3815     ]);
3816
3817     if ($xact->summary) {
3818         $date = $xact->summary->last_payment_ts || 
3819                 $xact->summary->last_billing_ts || 
3820                 $self->circ->due_date;
3821     }
3822
3823     return $date;
3824 }
3825
3826
3827 sub checkin_handle_lost_or_longoverdue {
3828     my ($self, %args) = @_;
3829
3830     my $circ = $self->circ;
3831     my $max_return = $args{max_return};
3832     my $circ_lib = $args{circ_lib};
3833
3834     if ($max_return) {
3835
3836         my $last_activity = 
3837             $self->get_circ_last_billing_activity(
3838                 $circ_lib, $args{ous_use_last_activity});
3839
3840         my $today = time();
3841         my @tm = reverse($last_activity =~ /([\d\.]+)/og);
3842         $tm[5] -= 1 if $tm[5] > 0;
3843         my $due = timelocal(int($tm[1]), int($tm[2]), 
3844             int($tm[3]), int($tm[4]), int($tm[5]), int($tm[6]));
3845
3846         my $last_chance = 
3847             OpenILS::Utils::DateTime->interval_to_seconds($max_return) + int($due);
3848
3849         $logger->info("MAX OD: $max_return LAST ACTIVITY: ".
3850             "$last_activity DUEDATE: ".$circ->due_date." TODAY: $today ".
3851                 "DUE: $due LAST: $last_chance");
3852
3853         $max_return = 0 if $today < $last_chance;
3854     }
3855
3856
3857     if ($max_return) {
3858
3859         $logger->info("circulator: check-in of lost/lo item exceeds max ". 
3860             "return interval.  skipping fine/fee voiding, etc.");
3861
3862     } elsif ($self->dont_change_lost_zero) { # we leave lost zero balance alone
3863
3864         $logger->info("circulator: check-in of lost/lo item having a balance ".
3865             "of zero, skipping fine/fee voiding and reinstatement.");
3866
3867     } else { # within max-return interval or no interval defined
3868
3869         $logger->info("circulator: check-in of lost/lo item is within the ".
3870             "max return interval (or no interval is defined).  Proceeding ".
3871             "with fine/fee voiding, etc.");
3872
3873         $self->needs_lost_bill_handling(1);
3874     }
3875
3876     if ($circ_lib != $self->circ_lib) {
3877         # if the item is not home, check to see if we want to retain the
3878         # lost/longoverdue status at this point in the process
3879
3880         my $immediately_available = $U->ou_ancestor_setting_value($circ_lib, 
3881             $args{ous_immediately_available}, $self->editor) || 0;
3882
3883         if ($immediately_available) {
3884             # item status does not need to be retained, so give it a
3885             # reshelving status as if it were a normal checkin
3886             my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3887             $self->copy->status($U->copy_status($next_status));
3888             $self->update_copy;
3889         } else {
3890             $logger->info("circulator: leaving lost/longoverdue copy".
3891                 " status in place on checkin");
3892         }
3893     } else {
3894         # lost/longoverdue item is home and processed, treat like a normal 
3895         # checkin from this point on
3896         my $next_status = $self->next_copy_status->[0] || OILS_COPY_STATUS_RESHELVING;
3897         $self->copy->status($U->copy_status($next_status));
3898         $self->update_copy;
3899     }
3900 }
3901
3902
3903 sub checkin_handle_backdate {
3904     my $self = shift;
3905
3906     # ------------------------------------------------------------------
3907     # clean up the backdate for date comparison
3908     # XXX We are currently taking the due-time from the original due-date,
3909     # not the input.  Do we need to do this?  This certainly interferes with
3910     # backdating of hourly checkouts, but that is likely a very rare case.
3911     # ------------------------------------------------------------------
3912     my $bd = clean_ISO8601($self->backdate);
3913     my $original_date = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->circ->due_date));
3914     my $new_date = DateTime::Format::ISO8601->new->parse_datetime($bd);
3915     $new_date->set_hour($original_date->hour());
3916     $new_date->set_minute($original_date->minute());
3917     if ($new_date >= DateTime->now) {
3918         # We can't say that the item will be checked in later...so assume someone's clock is wrong instead.
3919         # $self->backdate() autoload handler ignores undef values.  
3920         # Clear the backdate manually.
3921         $logger->info("circulator: ignoring future backdate: $new_date");
3922         delete $self->{backdate};
3923     } else {
3924         $self->backdate(clean_ISO8601($new_date->datetime()));
3925     }
3926
3927     return undef;
3928 }
3929
3930
3931 sub check_checkin_copy_status {
3932     my $self = shift;
3933    my $copy = $self->copy;
3934
3935    my $status = $U->copy_status($copy->status)->id;
3936
3937    return undef
3938       if(   $self->new_copy_alerts ||
3939             $status == OILS_COPY_STATUS_AVAILABLE   ||
3940             $status == OILS_COPY_STATUS_CHECKED_OUT ||
3941             $status == OILS_COPY_STATUS_IN_PROCESS  ||
3942             $status == OILS_COPY_STATUS_ON_HOLDS_SHELF  ||
3943             $status == OILS_COPY_STATUS_IN_TRANSIT  ||
3944             $status == OILS_COPY_STATUS_CATALOGING  ||
3945             $status == OILS_COPY_STATUS_ON_RESV_SHELF  ||
3946             $status == OILS_COPY_STATUS_CANCELED_TRANSIT ||
3947             $status == OILS_COPY_STATUS_RESHELVING );
3948
3949    return OpenILS::Event->new('COPY_STATUS_LOST', payload => $copy )
3950       if( $status == OILS_COPY_STATUS_LOST );
3951
3952     return OpenILS::Event->new('COPY_STATUS_LOST_AND_PAID', payload => $copy)
3953         if ($status == OILS_COPY_STATUS_LOST_AND_PAID);
3954
3955    return OpenILS::Event->new('COPY_STATUS_LONG_OVERDUE', payload => $copy )
3956       if( $status == OILS_COPY_STATUS_LONG_OVERDUE );
3957
3958    return OpenILS::Event->new('COPY_STATUS_MISSING', payload => $copy )
3959       if( $status == OILS_COPY_STATUS_MISSING );
3960
3961    return OpenILS::Event->new('COPY_BAD_STATUS', payload => $copy );
3962 }
3963
3964
3965
3966 # --------------------------------------------------------------------------
3967 # On checkin, we need to return as many relevant objects as we can
3968 # --------------------------------------------------------------------------
3969 sub checkin_flesh_events {
3970     my $self = shift;
3971
3972     if( grep { $_->{textcode} eq 'SUCCESS' } @{$self->events} 
3973         and grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events} ) {
3974             $self->events([grep { $_->{textcode} eq 'ITEM_NOT_CATALOGED' } @{$self->events}]);
3975     }
3976
3977     my $record = $U->record_to_mvr($self->title) if($self->title and !$self->is_precat);
3978
3979     my $hold;
3980     if($self->hold and !$self->hold->cancel_time) {
3981         $hold = $self->hold;
3982         $hold->notes($self->editor->search_action_hold_request_note({hold => $hold->id}));
3983     }
3984
3985     if($self->circ) {
3986         # update our copy of the circ object and 
3987         # flesh the billing summary data
3988         $self->circ(
3989             $self->editor->retrieve_action_circulation([
3990                 $self->circ->id, {
3991                     flesh => 2,
3992                     flesh_fields => {
3993                         circ => ['billable_transaction'],
3994                         mbt => ['summary']
3995                     }
3996                 }
3997             ])
3998         );
3999     }
4000
4001     if($self->patron) {
4002         # flesh some patron fields before returning
4003         $self->patron(
4004             $self->editor->retrieve_actor_user([
4005                 $self->patron->id,
4006                 {
4007                     flesh => 1,
4008                     flesh_fields => {
4009                         au => ['card', 'billing_address', 'mailing_address']
4010                     }
4011                 }
4012             ])
4013         );
4014     }
4015
4016     if ($self->latest_inventory) {
4017         # flesh some workstation fields before returning
4018         $self->latest_inventory->inventory_workstation(
4019             $self->editor->retrieve_actor_workstation([$self->latest_inventory->inventory_workstation])
4020         );
4021     }
4022
4023     if($self->latest_inventory && !$self->latest_inventory->id) {
4024         my $alci = $self->editor->search_asset_latest_inventory(
4025             {copy => $self->latest_inventory->copy}
4026         );
4027         if($alci->[0]) {
4028             $self->latest_inventory->id($alci->[0]->id);
4029         }
4030     }
4031     $self->copy->latest_inventory($self->latest_inventory);
4032
4033     for my $evt (@{$self->events}) {
4034
4035         my $payload         = {};
4036         $payload->{copy}    = $U->unflesh_copy($self->copy);
4037         $payload->{volume}  = $self->volume;
4038         $payload->{record}  = $record,
4039         $payload->{circ}    = $self->circ;
4040         $payload->{transit} = $self->transit;
4041         $payload->{cancelled_hold_transit} = 1 if $self->cancelled_hold_transit;
4042         $payload->{hold}    = $hold;
4043         $payload->{patron}  = $self->patron;
4044         $payload->{reservation} = $self->reservation
4045             unless (not $self->reservation or $self->reservation->cancel_time);
4046         $payload->{latest_inventory} = $self->latest_inventory;
4047         if ($self->do_inventory_update) { $payload->{do_inventory_update} = 1; }
4048
4049         $evt->{payload}     = $payload;
4050     }
4051 }
4052
4053 sub log_me {
4054     my( $self, $msg ) = @_;
4055     my $bc = ($self->copy) ? $self->copy->barcode :
4056         $self->barcode;
4057     $bc ||= "";
4058     my $usr = ($self->patron) ? $self->patron->id : "";
4059     $logger->info("circulator: $msg requestor=".$self->editor->requestor->id.
4060         ", recipient=$usr, copy=$bc");
4061 }
4062
4063
4064 sub do_renew {
4065     my $self = shift;
4066     my $api = shift;
4067     $self->log_me("do_renew()");
4068
4069     # Make sure there is an open circ to renew
4070     my $usrid = $self->patron->id if $self->patron;
4071     my $circ = $self->editor->search_action_circulation({
4072         target_copy => $self->copy->id,
4073         xact_finish => undef,
4074         checkin_time => undef,
4075         ($usrid ? (usr => $usrid) : ())
4076     })->[0];
4077
4078     return $self->bail_on_events($self->editor->event) unless $circ;
4079
4080     # A user is not allowed to renew another user's items without permission
4081     unless( $circ->usr eq $self->editor->requestor->id ) {
4082         return $self->bail_on_events($self->editor->events)
4083             unless $self->editor->allowed('RENEW_CIRC', $circ->circ_lib);
4084     }   
4085
4086     $self->push_events(OpenILS::Event->new('MAX_RENEWALS_REACHED'))
4087         if $circ->renewal_remaining < 1;
4088
4089     $self->push_events(OpenILS::Event->new('MAX_AUTO_RENEWALS_REACHED'))
4090         if $self->auto_renewal and $circ->auto_renewal_remaining < 1;
4091     # -----------------------------------------------------------------
4092
4093     $self->parent_circ($circ->id);
4094     $self->renewal_remaining( $circ->renewal_remaining - 1 );
4095     $self->auto_renewal_remaining( $circ->auto_renewal_remaining - 1 ) if (defined($circ->auto_renewal_remaining));
4096     $self->circ($circ);
4097
4098     # Opac renewal - re-use circ library from original circ (unless told not to)
4099     if($self->opac_renewal or $self->auto_renewal) {
4100         unless(defined($opac_renewal_use_circ_lib)) {
4101             my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.opac_renewal.use_original_circ_lib');
4102             if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4103                 $opac_renewal_use_circ_lib = 1;
4104             }
4105             else {
4106                 $opac_renewal_use_circ_lib = 0;
4107             }
4108         }
4109         $self->circ_lib($circ->circ_lib) if($opac_renewal_use_circ_lib);
4110     }
4111
4112     # Desk renewal - re-use circ library from original circ (unless told not to)
4113     if($self->desk_renewal) {
4114         unless(defined($desk_renewal_use_circ_lib)) {
4115             my $use_circ_lib = $self->editor->retrieve_config_global_flag('circ.desk_renewal.use_original_circ_lib');
4116             if($use_circ_lib and $U->is_true($use_circ_lib->enabled)) {
4117                 $desk_renewal_use_circ_lib = 1;
4118             }
4119             else {
4120                 $desk_renewal_use_circ_lib = 0;
4121             }
4122         }
4123         $self->circ_lib($circ->circ_lib) if($desk_renewal_use_circ_lib);
4124     }
4125
4126     # Check if expired patron is allowed to renew, and bail if not.
4127     my $expire = DateTime::Format::ISO8601->new->parse_datetime(clean_ISO8601($self->patron->expire_date));
4128     if (CORE::time > $expire->epoch) {
4129         my $allow_renewal = $U->ou_ancestor_setting_value($self->circ_lib, OILS_SETTING_ALLOW_RENEW_FOR_EXPIRED_PATRON);
4130         unless ($U->is_true($allow_renewal)) {
4131             return $self->bail_on_events(OpenILS::Event->new('PATRON_ACCOUNT_EXPIRED'));
4132         }
4133     }
4134
4135     # Run the fine generator against the old circ
4136     # XXX This seems unnecessary, given that handle_fines runs in do_checkin
4137     # a few lines down.  Commenting out, for now.
4138     #$self->handle_fines;
4139
4140     $self->run_renew_permit;
4141
4142     # Check the item in
4143     $self->do_checkin();
4144     return if $self->bail_out;
4145
4146     unless( $self->permit_override ) {
4147         $self->do_permit();
4148         return if $self->bail_out;
4149         $self->is_precat(1) if $self->have_event('ITEM_NOT_CATALOGED');
4150         $self->remove_event('ITEM_NOT_CATALOGED');
4151     }   
4152
4153     $self->override_events;
4154     return if $self->bail_out;
4155
4156     $self->events([]);
4157     $self->do_checkout();
4158 }
4159
4160
4161 sub remove_event {
4162     my( $self, $evt ) = @_;
4163     $evt = (ref $evt) ? $evt->{textcode} : $evt;
4164     $logger->debug("circulator: removing event from list: $evt");
4165     my @events = @{$self->events};
4166     $self->events( [ grep { $_->{textcode} ne $evt } @events ] );
4167 }
4168
4169
4170 sub have_event {
4171     my( $self, $evt ) = @_;
4172     $evt = (ref $evt) ? $evt->{textcode} : $evt;
4173     return grep { $_->{textcode} eq $evt } @{$self->events};
4174 }
4175
4176
4177 sub run_renew_permit {
4178     my $self = shift;
4179
4180     if ($U->ou_ancestor_setting_value($self->circ_lib, 'circ.block_renews_for_holds')) {
4181         my ($hold, undef, $retarget) = $holdcode->find_nearest_permitted_hold(
4182             $self->editor, $self->copy, $self->editor->requestor, 1
4183         );
4184         $self->push_events(new OpenILS::Event("COPY_NEEDED_FOR_HOLD")) if $hold;
4185     }
4186
4187     my $results = $self->run_indb_circ_test;
4188     $self->push_events($self->matrix_test_result_events)
4189         unless $self->circ_test_success;
4190 }
4191
4192
4193 # XXX: The primary mechanism for storing circ history is now handled
4194 # by tracking real circulation objects instead of bibs in a bucket.
4195 # However, this code is disabled by default and could be useful 
4196 # some day, so may as well leave it for now.
4197 sub append_reading_list {
4198     my $self = shift;
4199
4200     return undef unless 
4201         $self->is_checkout and 
4202         $self->patron and 
4203         $self->copy and 
4204         !$self->is_noncat;
4205
4206
4207     # verify history is globally enabled and uses the bucket mechanism
4208     my $htype = OpenSRF::Utils::SettingsClient->new->config_value(
4209         apps => 'open-ils.circ' => app_settings => 'checkout_history_mechanism');
4210
4211     return undef unless $htype and $htype eq 'bucket';
4212
4213     my $e = new_editor(xact => 1, requestor => $self->editor->requestor);
4214
4215     # verify the patron wants to retain the hisory
4216     my $setting = $e->search_actor_user_setting(
4217         {usr => $self->patron->id, name => 'circ.keep_checkout_history'})->[0];
4218     
4219     unless($setting and $setting->value) {
4220         $e->rollback;
4221         return undef;
4222     }
4223
4224     my $bkt = $e->search_container_copy_bucket(
4225         {owner => $self->patron->id, btype => 'circ_history'})->[0];
4226
4227     my $pos = 1;
4228
4229     if($bkt) {
4230         # find the next item position
4231         my $last_item = $e->search_container_copy_bucket_item(
4232             {bucket => $bkt->id}, {order_by => {ccbi => 'pos desc'}, limit => 1})->[0];
4233         $pos = $last_item->pos + 1 if $last_item;
4234
4235     } else {
4236         # create the history bucket if necessary
4237         $bkt = Fieldmapper::container::copy_bucket->new;
4238         $bkt->owner($self->patron->id);
4239         $bkt->name('');
4240         $bkt->btype('circ_history');
4241         $bkt->pub('f');
4242         $e->create_container_copy_bucket($bkt) or return $e->die_event;
4243     }
4244
4245     my $item = Fieldmapper::container::copy_bucket_item->new;
4246
4247     $item->bucket($bkt->id);
4248     $item->target_copy($self->copy->id);
4249     $item->pos($pos);
4250
4251     $e->create_container_copy_bucket_item($item) or return $e->die_event;
4252     $e->commit;
4253
4254     return undef;
4255 }
4256
4257
4258 sub make_trigger_events {
4259     my $self = shift;
4260     return unless $self->circ;
4261     $U->create_events_for_hook('checkout', $self->circ, $self->circ_lib) if $self->is_checkout;
4262     $U->create_events_for_hook('checkin',  $self->circ, $self->circ_lib) if $self->is_checkin;
4263     $U->create_events_for_hook('renewal',  $self->circ, $self->circ_lib) if $self->is_renewal;
4264 }
4265
4266
4267
4268 sub checkin_handle_lost_or_lo_now_found {
4269     my ($self, $bill_type, $is_longoverdue) = @_;
4270
4271     my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4272
4273     $logger->debug("voiding $tag item billings");
4274     my $result = $CC->void_or_zero_bills_of_type($self->editor, $self->circ, $self->copy, $bill_type, "$tag ITEM RETURNED");
4275     $self->bail_on_events($self->editor->event) if ($result);
4276 }
4277
4278 sub checkin_handle_lost_or_lo_now_found_restore_od {
4279     my $self = shift;
4280     my $circ_lib = shift;
4281     my $is_longoverdue = shift;
4282     my $tag = $is_longoverdue ? "LONGOVERDUE" : "LOST";
4283
4284     # ------------------------------------------------------------------
4285     # restore those overdue charges voided when item was set to lost
4286     # ------------------------------------------------------------------
4287
4288     my $ods = $self->editor->search_money_billing([
4289         {
4290             xact => $self->circ->id,
4291             btype => 1
4292         },
4293         {
4294             order_by => {mb => 'billing_ts desc'}
4295         }
4296     ]);
4297
4298     $logger->debug("returning ".scalar(@$ods)." overdue charges pre-$tag");
4299     # Because actual users get up to all kinds of unexpectedness, we
4300     # only recreate up to $circ->max_fine in bills.  I know you think
4301     # it wouldn't happen that bills could get created, voided, and
4302     # recreated more than once, but I guaran-damn-tee you that it will
4303     # happen.
4304     if ($ods && @$ods) {
4305         my $void_amount = 0;
4306         my $void_max = $self->circ->max_fine();
4307         # search for overdues voided the new way (aka "adjusted")
4308         my @billings = map {$_->id()} @$ods;
4309         my $voids = $self->editor->search_money_account_adjustment(
4310             {
4311                 billing => \@billings
4312             }
4313         );
4314         if (@$voids) {
4315             map {$void_amount += $_->amount()} @$voids;
4316         } else {
4317             # if no adjustments found, assume they were voided the old way (aka "voided")
4318             for my $bill (@$ods) {
4319                 if( $U->is_true($bill->voided) ) {
4320                     $void_amount += $bill->amount();
4321                 }
4322             }
4323         }
4324         $CC->create_bill(
4325             $self->editor,
4326             ($void_amount < $void_max ? $void_amount : $void_max),
4327             $ods->[0]->btype(),
4328             $ods->[0]->billing_type(),
4329             $self->circ->id(),
4330             "System: $tag RETURNED - OVERDUES REINSTATED",
4331             $ods->[-1]->period_start(),
4332             $ods->[0]->period_end() # date this restoration the same as the last overdue (for possible subsequent fine generation)
4333         );
4334     }
4335 }
4336
4337 1;